mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-12 10:47:31 +02:00
feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
This commit is contained in:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
@@ -0,0 +1,354 @@
|
||||
//! Shared OAuth state and one-time callback ownership for managed runtimes.
|
||||
|
||||
use aes_gcm::aead::{OsRng, rand_core::RngCore};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
mod client;
|
||||
pub use client::ExtensionAuth;
|
||||
|
||||
const SECOND: i128 = 1_000_000_000;
|
||||
const PENDING_TTL: i128 = 180 * SECOND;
|
||||
|
||||
pub trait AuthClock: Send + Sync {
|
||||
fn now_nanos(&self) -> i128;
|
||||
}
|
||||
|
||||
struct SystemClock;
|
||||
impl AuthClock for SystemClock {
|
||||
fn now_nanos(&self) -> i128 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or_else(
|
||||
|error| -(error.duration().as_nanos() as i128),
|
||||
|time| time.as_nanos() as i128,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct PendingAuthRequest {
|
||||
pub extension_id: String,
|
||||
pub auth_url: String,
|
||||
pub callback_url: String,
|
||||
#[serde(skip)]
|
||||
pub state: String,
|
||||
#[serde(skip)]
|
||||
pub created_at: i128,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Record {
|
||||
code: Zeroizing<String>,
|
||||
access_token: Zeroizing<String>,
|
||||
refresh_token: Zeroizing<String>,
|
||||
verifier: Zeroizing<String>,
|
||||
challenge: String,
|
||||
authenticated: bool,
|
||||
expires_at: Option<i128>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
records: BTreeMap<String, Record>,
|
||||
generations: BTreeMap<String, u64>,
|
||||
pending: BTreeMap<String, PendingAuthRequest>,
|
||||
owners: BTreeMap<String, String>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
pub struct AuthRegistry {
|
||||
state: Mutex<State>,
|
||||
clock: Arc<dyn AuthClock>,
|
||||
}
|
||||
|
||||
impl Default for AuthRegistry {
|
||||
fn default() -> Self {
|
||||
Self::with_clock(Arc::new(SystemClock))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthRegistry {
|
||||
pub fn with_clock(clock: Arc<dyn AuthClock>) -> Self {
|
||||
Self {
|
||||
state: Mutex::default(),
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now(&self) -> i128 {
|
||||
self.clock.now_nanos()
|
||||
}
|
||||
|
||||
fn edit<T>(&self, id: &str, edit: impl FnOnce(&mut Record) -> T) -> Result<T, String> {
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
if state.closed {
|
||||
return Err("extension environment closed".into());
|
||||
}
|
||||
let generation = state.generations.entry(id.to_owned()).or_default();
|
||||
*generation = generation.wrapping_add(1);
|
||||
Ok(edit(state.records.entry(id.to_owned()).or_default()))
|
||||
}
|
||||
|
||||
pub fn set_code(&self, id: &str, code: &str) -> Result<(), String> {
|
||||
self.edit(id, |record| record.code = Zeroizing::new(code.to_owned()))
|
||||
}
|
||||
|
||||
pub fn set_tokens(
|
||||
&self,
|
||||
id: &str,
|
||||
access: &str,
|
||||
refresh: &str,
|
||||
expires_in: i64,
|
||||
) -> Result<(), String> {
|
||||
let expires = (expires_in > 0)
|
||||
.then(|| self.now() + i128::from(expires_in.wrapping_mul(1_000_000_000)));
|
||||
self.edit(id, |record| {
|
||||
record.access_token = Zeroizing::new(access.to_owned());
|
||||
record.refresh_token = Zeroizing::new(refresh.to_owned());
|
||||
record.authenticated = !access.is_empty();
|
||||
record.expires_at = expires;
|
||||
})
|
||||
}
|
||||
|
||||
pub fn code(&self, id: &str) -> Option<String> {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("auth registry lock")
|
||||
.records
|
||||
.get(id)
|
||||
.filter(|record| !record.code.is_empty())
|
||||
.map(|record| record.code.to_string())
|
||||
}
|
||||
|
||||
pub fn authenticated(&self, id: &str) -> bool {
|
||||
let now = self.now();
|
||||
self.state
|
||||
.lock()
|
||||
.expect("auth registry lock")
|
||||
.records
|
||||
.get(id)
|
||||
.is_some_and(|record| {
|
||||
record.authenticated && record.expires_at.is_none_or(|expires| now <= expires)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tokens(&self, id: &str) -> Value {
|
||||
let state = self.state.lock().expect("auth registry lock");
|
||||
let Some(record) = state.records.get(id) else {
|
||||
return json!({});
|
||||
};
|
||||
let mut result = json!({"access_token":record.access_token.as_str(),"refresh_token":record.refresh_token.as_str(),"is_authenticated":record.authenticated});
|
||||
if let Some(expires) = record.expires_at {
|
||||
result["expires_at"] = json!(expires.div_euclid(SECOND) as i64);
|
||||
result["is_expired"] = json!(self.now() > expires);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn clear(&self, id: &str) {
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
state.records.remove(id);
|
||||
let generation = state.generations.entry(id.to_owned()).or_default();
|
||||
*generation = generation.wrapping_add(1);
|
||||
Self::remove_pending(&mut state, id, self.now());
|
||||
}
|
||||
|
||||
pub fn register_pending(&self, mut request: PendingAuthRequest) -> Result<(), String> {
|
||||
if request.extension_id.trim().is_empty() {
|
||||
return Err("extension id is required".into());
|
||||
}
|
||||
if request.state.is_empty() {
|
||||
request.state = callback_state()?;
|
||||
}
|
||||
if request.created_at == 0 {
|
||||
request.created_at = self.now();
|
||||
}
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
if state.closed {
|
||||
return Err("extension environment closed".into());
|
||||
}
|
||||
if let Some(owner) = state
|
||||
.owners
|
||||
.get(&request.state)
|
||||
.filter(|owner| **owner != request.extension_id)
|
||||
{
|
||||
let same = state.pending.get(owner).is_some_and(|previous| {
|
||||
previous.state == request.state
|
||||
&& previous.auth_url == request.auth_url
|
||||
&& previous.callback_url == request.callback_url
|
||||
&& previous.created_at == request.created_at
|
||||
});
|
||||
if !same {
|
||||
return Err("callback state is already registered".into());
|
||||
}
|
||||
}
|
||||
if state
|
||||
.pending
|
||||
.get(&request.extension_id)
|
||||
.is_some_and(|previous| previous.state != request.state)
|
||||
{
|
||||
Self::remove_pending(&mut state, &request.extension_id, self.now());
|
||||
}
|
||||
state
|
||||
.owners
|
||||
.entry(request.state.clone())
|
||||
.or_insert_with(|| request.extension_id.clone());
|
||||
state.pending.insert(request.extension_id.clone(), request);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_pending(state: &mut State, id: &str, now: i128) {
|
||||
let Some(request) = state.pending.remove(id) else {
|
||||
return;
|
||||
};
|
||||
if state
|
||||
.owners
|
||||
.get(&request.state)
|
||||
.is_none_or(|owner| owner != id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.owners.remove(&request.state);
|
||||
if let Some((id, _)) = state.pending.iter().find(|(_, candidate)| {
|
||||
candidate.state == request.state && now - candidate.created_at < PENDING_TTL
|
||||
}) {
|
||||
state.owners.insert(request.state, id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_pending(&self, id: &str) {
|
||||
Self::remove_pending(
|
||||
&mut self.state.lock().expect("auth registry lock"),
|
||||
id,
|
||||
self.now(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn pending(&self, id: &str) -> Option<PendingAuthRequest> {
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
if state
|
||||
.pending
|
||||
.get(id)
|
||||
.is_some_and(|request| self.now() - request.created_at >= PENDING_TTL)
|
||||
{
|
||||
Self::remove_pending(&mut state, id, self.now());
|
||||
}
|
||||
state.pending.get(id).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn has_fresh_challenge(&self, id: &str) -> bool {
|
||||
self.pending(id).is_some_and(|request| {
|
||||
request.extension_id == id
|
||||
&& !request.auth_url.trim().is_empty()
|
||||
&& (0..PENDING_TTL).contains(&(self.now() - request.created_at))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn all_pending(&self) -> Vec<PendingAuthRequest> {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("auth registry lock")
|
||||
.pending
|
||||
.values()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn resolve_callback(&self, nonce: &str, consume: bool) -> Result<String, String> {
|
||||
let nonce = nonce.trim();
|
||||
if nonce.is_empty() {
|
||||
return Err("callback state is required".into());
|
||||
}
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
let owner = state.owners.get(nonce).cloned();
|
||||
let valid = owner
|
||||
.as_ref()
|
||||
.and_then(|owner| state.pending.get(owner))
|
||||
.is_some_and(|request| {
|
||||
request.state == nonce && self.now() - request.created_at < PENDING_TTL
|
||||
});
|
||||
if !valid || consume {
|
||||
state.owners.remove(nonce);
|
||||
state.pending.retain(|_, request| request.state != nonce);
|
||||
}
|
||||
if valid {
|
||||
Ok(owner.expect("validated callback owner"))
|
||||
} else {
|
||||
Err("callback state is invalid, expired, or already used".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let mut state = self.state.lock().expect("auth registry lock");
|
||||
*state = State {
|
||||
closed: true,
|
||||
..State::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn callback_state() -> Result<String, String> {
|
||||
let mut random = Zeroizing::new([0_u8; 32]);
|
||||
OsRng
|
||||
.try_fill_bytes(random.as_mut())
|
||||
.map_err(|error| format!("generate callback state: {error}"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(random.as_ref()))
|
||||
}
|
||||
|
||||
pub fn pkce_verifier(length: usize) -> Result<String, String> {
|
||||
let length = length.clamp(43, 128);
|
||||
let mut random = Zeroizing::new(vec![0; length]);
|
||||
OsRng
|
||||
.try_fill_bytes(&mut random)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut verifier = URL_SAFE_NO_PAD.encode(random.as_slice());
|
||||
verifier.truncate(length);
|
||||
Ok(verifier)
|
||||
}
|
||||
|
||||
pub fn pkce_challenge(verifier: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Clock;
|
||||
impl AuthClock for Clock {
|
||||
fn now_nanos(&self) -> i128 {
|
||||
1000 * SECOND
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_requires_own_nonempty_challenge_between_zero_and_180_seconds_old() {
|
||||
for (age, url, expected) in [
|
||||
(-1, "https://example.test/verify", false),
|
||||
(0, "https://example.test/verify", true),
|
||||
(179, "https://example.test/verify", true),
|
||||
(180, "https://example.test/verify", false),
|
||||
(0, " \n", false),
|
||||
] {
|
||||
let auth = AuthRegistry::with_clock(Arc::new(Clock));
|
||||
auth.register_pending(PendingAuthRequest {
|
||||
extension_id: "example.auth".into(),
|
||||
auth_url: url.into(),
|
||||
callback_url: "spotiflac://callback".into(),
|
||||
state: "state".into(),
|
||||
created_at: (1000 - age) * SECOND,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(auth.has_fresh_challenge("example.auth"), expected);
|
||||
assert!(!auth.has_fresh_challenge("example.other"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use super::{AuthRegistry, PendingAuthRequest, callback_state, pkce_challenge, pkce_verifier};
|
||||
use serde_json::{Map, Value, json};
|
||||
use spotiflac_core::app_version::AppVersion;
|
||||
use spotiflac_network::{HttpRequest, NetworkSession, query};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub struct ExtensionAuth {
|
||||
id: String,
|
||||
registry: Arc<AuthRegistry>,
|
||||
network: Arc<NetworkSession>,
|
||||
app_version: AppVersion,
|
||||
}
|
||||
|
||||
impl ExtensionAuth {
|
||||
pub fn new(
|
||||
id: &str,
|
||||
registry: Arc<AuthRegistry>,
|
||||
network: Arc<NetworkSession>,
|
||||
app_version: impl Into<AppVersion>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
registry,
|
||||
network,
|
||||
app_version: app_version.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn call(
|
||||
&self,
|
||||
method: &str,
|
||||
arguments: &[Value],
|
||||
expires_is_float: bool,
|
||||
check: impl Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
check()?;
|
||||
let first = arguments.first().unwrap_or(&Value::Null);
|
||||
match method {
|
||||
"getAuthCode" => Ok(self
|
||||
.registry
|
||||
.code(&self.id)
|
||||
.map_or(Value::Null, Value::String)),
|
||||
"getTokens" => Ok(self.registry.tokens(&self.id)),
|
||||
"isAuthenticated" => Ok(json!(self.registry.authenticated(&self.id))),
|
||||
"clearAuth" => {
|
||||
self.registry.clear(&self.id);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"setAuthCode" => {
|
||||
if arguments.is_empty() {
|
||||
return Ok(json!(false));
|
||||
}
|
||||
self.registry.edit(&self.id, |record| {
|
||||
if let Some(code) = first.as_str() {
|
||||
record.code = Zeroizing::new(code.to_owned());
|
||||
} else if let Some(value) = first.as_object() {
|
||||
if let Some(code) = value.get("code").and_then(Value::as_str) {
|
||||
record.code = Zeroizing::new(code.to_owned());
|
||||
}
|
||||
if let Some(access) = value.get("access_token").and_then(Value::as_str) {
|
||||
record.access_token = Zeroizing::new(access.to_owned());
|
||||
record.authenticated = true;
|
||||
}
|
||||
if let Some(refresh) = value.get("refresh_token").and_then(Value::as_str) {
|
||||
record.refresh_token = Zeroizing::new(refresh.to_owned());
|
||||
}
|
||||
if expires_is_float
|
||||
&& let Some(expires) = value.get("expires_in").and_then(Value::as_f64)
|
||||
{
|
||||
record.expires_at = Some(
|
||||
self.registry.now()
|
||||
+ i128::from((expires as i64).wrapping_mul(1_000_000_000)),
|
||||
);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"generatePKCE" => {
|
||||
let length = first.as_u64().unwrap_or(64) as usize;
|
||||
let verifier = pkce_verifier(length)?;
|
||||
self.store_pkce(&verifier, false)?;
|
||||
Ok(
|
||||
json!({"verifier":verifier,"challenge":pkce_challenge(&verifier),"method":"S256"}),
|
||||
)
|
||||
}
|
||||
"getPKCE" => {
|
||||
let state = self.registry.state.lock().expect("auth registry lock");
|
||||
Ok(state.records.get(&self.id).filter(|record| !record.verifier.is_empty())
|
||||
.map_or_else(|| json!({}), |record| json!({"verifier":record.verifier.as_str(),"challenge":record.challenge,"method":"S256"})))
|
||||
}
|
||||
"openAuthUrl" => {
|
||||
if arguments.is_empty() {
|
||||
return Err("auth URL is required".into());
|
||||
}
|
||||
let mut url = self
|
||||
.network
|
||||
.validate_auth_url(first.as_str().unwrap_or(""), &check)?;
|
||||
let nonce = callback_state()?;
|
||||
let mut params = query::parse(&url.raw_query);
|
||||
query::set(&mut params, "state", &nonce);
|
||||
url.raw_query = query::encode(¶ms);
|
||||
self.registry.register_pending(PendingAuthRequest {
|
||||
extension_id: self.id.clone(),
|
||||
auth_url: url.display_url(),
|
||||
callback_url: arguments
|
||||
.get(1)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
state: nonce,
|
||||
created_at: self.registry.now(),
|
||||
})?;
|
||||
self.registry.set_code(&self.id, "")?;
|
||||
Ok(json!({"success":true,"message":"Auth URL will be opened by the app"}))
|
||||
}
|
||||
"startOAuthWithPKCE" | "exchangeCodeWithPKCE" => {
|
||||
if arguments.is_empty() {
|
||||
return Err("config object is required".into());
|
||||
}
|
||||
let config = first
|
||||
.as_object()
|
||||
.ok_or_else(|| "config must be an object".to_owned())?;
|
||||
if method == "startOAuthWithPKCE" {
|
||||
self.start(config, &check)
|
||||
} else {
|
||||
self.exchange(config, &check)
|
||||
}
|
||||
}
|
||||
_ => Err("unknown auth method".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_pkce(&self, verifier: &str, clear_code: bool) -> Result<(), String> {
|
||||
self.registry.edit(&self.id, |record| {
|
||||
record.verifier = Zeroizing::new(verifier.to_owned());
|
||||
record.challenge = pkce_challenge(verifier);
|
||||
if clear_code {
|
||||
record.code = Zeroizing::default();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
config: &Map<String, Value>,
|
||||
check: &impl Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let auth_url = string(config, "authUrl");
|
||||
let client = string(config, "clientId");
|
||||
let redirect = string(config, "redirectUri");
|
||||
if auth_url.is_empty() || client.is_empty() || redirect.is_empty() {
|
||||
return Err("authUrl, clientId, and redirectUri are required".into());
|
||||
}
|
||||
let mut url = self.network.validate_auth_url(auth_url, check)?;
|
||||
let verifier = Zeroizing::new(
|
||||
pkce_verifier(64).map_err(|error| format!("failed to generate PKCE: {error}"))?,
|
||||
);
|
||||
let challenge = pkce_challenge(&verifier);
|
||||
self.store_pkce(&verifier, true)?;
|
||||
let mut params = query::parse(&url.raw_query);
|
||||
for (key, value) in [
|
||||
("client_id", client),
|
||||
("redirect_uri", redirect),
|
||||
("response_type", "code"),
|
||||
("code_challenge", &challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
] {
|
||||
query::set(&mut params, key, value);
|
||||
}
|
||||
let scope = string(config, "scope");
|
||||
if !scope.is_empty() {
|
||||
query::set(&mut params, "scope", scope);
|
||||
}
|
||||
extra_params(config, &mut params);
|
||||
let nonce =
|
||||
callback_state().map_err(|error| format!("failed to generate OAuth state: {error}"))?;
|
||||
query::set(&mut params, "state", &nonce);
|
||||
url.raw_query = query::encode(¶ms);
|
||||
let auth_url = url.display_url();
|
||||
self.registry
|
||||
.register_pending(PendingAuthRequest {
|
||||
extension_id: self.id.clone(),
|
||||
auth_url: auth_url.clone(),
|
||||
callback_url: redirect.to_owned(),
|
||||
state: nonce,
|
||||
created_at: self.registry.now(),
|
||||
})
|
||||
.map_err(|error| format!("failed to register OAuth callback: {error}"))?;
|
||||
Ok(
|
||||
json!({"success":true,"authUrl":auth_url,"pkce":{"verifier":verifier.as_str(),"challenge":challenge,"method":"S256"}}),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange(
|
||||
&self,
|
||||
config: &Map<String, Value>,
|
||||
check: &impl Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let token_url = string(config, "tokenUrl");
|
||||
let client = string(config, "clientId");
|
||||
let code = string(config, "code");
|
||||
if token_url.is_empty() || client.is_empty() || code.is_empty() {
|
||||
return Err("tokenUrl, clientId, and code are required".into());
|
||||
}
|
||||
let (verifier, generation) = {
|
||||
let state = self.registry.state.lock().expect("auth registry lock");
|
||||
let verifier = state
|
||||
.records
|
||||
.get(&self.id)
|
||||
.map(|record| record.verifier.clone())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
verifier,
|
||||
state.generations.get(&self.id).copied().unwrap_or(0),
|
||||
)
|
||||
};
|
||||
if verifier.is_empty() {
|
||||
return Err(
|
||||
"no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first".into(),
|
||||
);
|
||||
}
|
||||
self.network.validate_url(token_url)?;
|
||||
let mut params = query::Query::new();
|
||||
for (key, value) in [
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", client),
|
||||
("code", code),
|
||||
("code_verifier", &verifier),
|
||||
] {
|
||||
query::set(&mut params, key, value);
|
||||
}
|
||||
let redirect = string(config, "redirectUri");
|
||||
if !redirect.is_empty() {
|
||||
query::set(&mut params, "redirect_uri", redirect);
|
||||
}
|
||||
extra_params(config, &mut params);
|
||||
let response = self.network.request(
|
||||
HttpRequest {
|
||||
url: token_url.to_owned(),
|
||||
method: "POST".into(),
|
||||
body: query::encode(¶ms),
|
||||
headers: BTreeMap::from([(
|
||||
"Content-Type".into(),
|
||||
"application/x-www-form-urlencoded".into(),
|
||||
)]),
|
||||
default_json: false,
|
||||
user_agent: self.app_version.user_agent(),
|
||||
},
|
||||
check,
|
||||
)?;
|
||||
let body = Zeroizing::new(response.body);
|
||||
let token = match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(Value::Object(token)) => token,
|
||||
Ok(Value::Null) => Map::new(),
|
||||
result => {
|
||||
let message = match result {
|
||||
Err(error) => error.to_string(),
|
||||
_ => "token response must be an object".into(),
|
||||
};
|
||||
return Ok(
|
||||
json!({"success":false,"error":format!("failed to parse token response: {message}"),"body":crate::redact::preview(&body, 1000)}),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Some(error) = token.get("error").and_then(Value::as_str) {
|
||||
return Ok(
|
||||
json!({"success":false,"error":error,"error_description":string(&token,"error_description")}),
|
||||
);
|
||||
}
|
||||
let access = string(&token, "access_token");
|
||||
let refresh = string(&token, "refresh_token");
|
||||
let expires = token
|
||||
.get("expires_in")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
if access.is_empty() {
|
||||
return Ok(
|
||||
json!({"success":false,"error":"no access_token in response","body":crate::redact::preview(&body, 1000)}),
|
||||
);
|
||||
}
|
||||
check()?;
|
||||
let mut state = self.registry.state.lock().expect("auth registry lock");
|
||||
if state.closed || state.generations.get(&self.id).copied().unwrap_or(0) != generation {
|
||||
return Err("authentication state changed during token exchange".into());
|
||||
}
|
||||
let record = state.records.entry(self.id.clone()).or_default();
|
||||
record.access_token = Zeroizing::new(access.to_owned());
|
||||
record.refresh_token = Zeroizing::new(refresh.to_owned());
|
||||
record.authenticated = true;
|
||||
if expires > 0.0 {
|
||||
record.expires_at = Some(
|
||||
self.registry.now() + i128::from((expires as i64).wrapping_mul(1_000_000_000)),
|
||||
);
|
||||
}
|
||||
record.verifier = Zeroizing::default();
|
||||
record.challenge.clear();
|
||||
let generation = state.generations.entry(self.id.clone()).or_default();
|
||||
*generation = generation.wrapping_add(1);
|
||||
let mut result = json!({"success":true,"access_token":access,"refresh_token":refresh,"token_type":token.get("token_type")});
|
||||
if expires > 0.0 {
|
||||
result["expires_in"] = json!(expires);
|
||||
}
|
||||
if let Some(scope) = token.get("scope").and_then(Value::as_str) {
|
||||
result["scope"] = json!(scope);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn string<'a>(config: &'a Map<String, Value>, key: &str) -> &'a str {
|
||||
config.get(key).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
// The JS bridge exports extraParams with Go's fmt formatting, rather than JSON.
|
||||
fn extra_params(config: &Map<String, Value>, params: &mut query::Query) {
|
||||
if let Some(extras) = config.get("extraParams").and_then(Value::as_object) {
|
||||
for (key, value) in extras {
|
||||
query::set(params, key, value.as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Root ownership for installed extensions and shared provider services.
|
||||
|
||||
mod cover;
|
||||
mod downloads;
|
||||
mod health;
|
||||
mod library;
|
||||
mod lyrics;
|
||||
mod metadata;
|
||||
mod musicbrainz;
|
||||
mod provider_metadata;
|
||||
mod reenrich;
|
||||
mod share;
|
||||
mod tags;
|
||||
|
||||
pub use lyrics::LyricsRequest;
|
||||
pub use metadata::MetadataOptions;
|
||||
|
||||
use crate::RuntimeLimits;
|
||||
use crate::environment::ExtensionEnvironment;
|
||||
use crate::lyrics::InstalledLyricsFetcher;
|
||||
use crate::manager::{ExtensionManager, ManagerError};
|
||||
use spotiflac_core::lyrics::config::{self, FetchOptions};
|
||||
use spotiflac_providers::deezer::DeezerClient;
|
||||
use spotiflac_providers::lyrics::{LyricsService, builtin::BuiltinLyricsClient};
|
||||
use spotiflac_providers::musicbrainz::MusicBrainzClient;
|
||||
use spotiflac_providers::resolver::{PlatformResolverChain, availability::AvailabilityService};
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// Retained by the native owner, never by a VM or provider worker. Shutdown
|
||||
/// finishes provider work and persistence before releasing directory locks.
|
||||
pub struct Backend {
|
||||
manager: Arc<ExtensionManager>,
|
||||
lyrics: Arc<LyricsService>,
|
||||
availability: Arc<AvailabilityService>,
|
||||
deezer: Arc<DeezerClient>,
|
||||
musicbrainz: MusicBrainzClient,
|
||||
share_cache: Mutex<share::Cache>,
|
||||
cover: cover::Cover,
|
||||
library_cover_directory: Mutex<String>,
|
||||
library_scan: library::ScanState,
|
||||
prepared_downloads: Mutex<downloads::PreparedDownloads>,
|
||||
health: Arc<health::Health>,
|
||||
closed: AtomicBool,
|
||||
operations: Mutex<usize>,
|
||||
idle: Condvar,
|
||||
lyrics_settings: Mutex<()>,
|
||||
shutdown: Mutex<Option<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn new(
|
||||
sources: &Path,
|
||||
data: &Path,
|
||||
master_key: &str,
|
||||
app_version: &str,
|
||||
limits: RuntimeLimits,
|
||||
) -> Result<Self, ManagerError> {
|
||||
let environment = ExtensionEnvironment::new(data, master_key, app_version)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
Self::with_environment(sources, environment, limits, MetadataOptions::default())
|
||||
}
|
||||
|
||||
/// Apply persisted settings before restoring caches that depend on them.
|
||||
pub fn with_lyrics_settings(
|
||||
sources: &Path,
|
||||
data: &Path,
|
||||
master_key: &str,
|
||||
app_version: &str,
|
||||
limits: RuntimeLimits,
|
||||
providers_json: &str,
|
||||
options_json: &str,
|
||||
) -> Result<Self, ManagerError> {
|
||||
let providers = config::decode_providers(providers_json)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
let mut options = FetchOptions::default();
|
||||
options
|
||||
.update_json(options_json)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
let environment = ExtensionEnvironment::new(data, master_key, app_version)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
Self::with_environment_and_lyrics(
|
||||
sources,
|
||||
environment,
|
||||
limits,
|
||||
MetadataOptions::default(),
|
||||
&providers,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
/// Transfer a native environment into this root. Provider configuration is
|
||||
/// native-owned and all services reuse the environment's network/policy.
|
||||
pub fn with_environment(
|
||||
sources: &Path,
|
||||
environment: ExtensionEnvironment,
|
||||
limits: RuntimeLimits,
|
||||
options: MetadataOptions,
|
||||
) -> Result<Self, ManagerError> {
|
||||
Self::with_environment_and_lyrics(
|
||||
sources,
|
||||
environment,
|
||||
limits,
|
||||
options,
|
||||
&[],
|
||||
FetchOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_environment_and_lyrics(
|
||||
sources: &Path,
|
||||
environment: ExtensionEnvironment,
|
||||
limits: RuntimeLimits,
|
||||
options: MetadataOptions,
|
||||
lyrics_providers: &[String],
|
||||
lyrics_options: FetchOptions,
|
||||
) -> Result<Self, ManagerError> {
|
||||
let manager = Arc::new(ExtensionManager::with_environment(
|
||||
sources,
|
||||
environment,
|
||||
limits,
|
||||
)?);
|
||||
let environment = manager.environment();
|
||||
let network = environment
|
||||
.network_service()
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
let deezer = Arc::new(
|
||||
DeezerClient::with_endpoint(&network, &options.deezer_endpoint)
|
||||
.map_err(|error| ManagerError(error.to_string()))?,
|
||||
);
|
||||
let musicbrainz = MusicBrainzClient::with_options(&network, options.musicbrainz)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
let resolver = PlatformResolverChain::with_options(&network, options.resolver)
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
let availability = Arc::new(AvailabilityService::new(Arc::new(resolver), deezer.clone()));
|
||||
let lyrics = Arc::new(LyricsService::new(Arc::new(InstalledLyricsFetcher::new(
|
||||
&manager,
|
||||
BuiltinLyricsClient::new(
|
||||
&network,
|
||||
environment.shared_app_version(),
|
||||
availability.clone(),
|
||||
),
|
||||
))));
|
||||
lyrics
|
||||
.set_providers(lyrics_providers)
|
||||
.and_then(|()| lyrics.set_options(lyrics_options))
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
lyrics
|
||||
.set_persistence_path(&environment.data_directory().join(".lyrics_cache.json"))
|
||||
.map_err(|error| ManagerError(error.to_string()))?;
|
||||
environment.attach_lyrics(&lyrics);
|
||||
let cover = cover::Cover::new(&network, environment.shared_app_version());
|
||||
let health = Arc::new(health::Health::new(
|
||||
&network,
|
||||
environment.shared_app_version(),
|
||||
));
|
||||
Ok(Self {
|
||||
manager,
|
||||
lyrics,
|
||||
availability,
|
||||
deezer,
|
||||
musicbrainz,
|
||||
share_cache: Mutex::default(),
|
||||
cover,
|
||||
library_cover_directory: Mutex::default(),
|
||||
library_scan: library::ScanState::default(),
|
||||
prepared_downloads: Mutex::default(),
|
||||
health,
|
||||
closed: AtomicBool::new(false),
|
||||
operations: Mutex::new(0),
|
||||
idle: Condvar::new(),
|
||||
lyrics_settings: Mutex::new(()),
|
||||
shutdown: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn lyrics(&self) -> Arc<LyricsService> {
|
||||
Arc::clone(&self.lyrics)
|
||||
}
|
||||
|
||||
pub fn availability(&self) -> Arc<AvailabilityService> {
|
||||
Arc::clone(&self.availability)
|
||||
}
|
||||
|
||||
pub fn get_app_version(&self) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
self.manager
|
||||
.environment()
|
||||
.get_app_version()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn set_app_version(&self, version: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
self.manager
|
||||
.environment()
|
||||
.set_app_version(version)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_extension_pending_auth_json(&self, id: &str) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let environment = self.manager.environment();
|
||||
if let Some(pending) = environment
|
||||
.pending_auth(id)
|
||||
.map_err(|error| error.to_string())?
|
||||
{
|
||||
return serde_json::to_string(&pending).map_err(|error| error.to_string());
|
||||
}
|
||||
// A missing or expired challenge may require network preflight. Reuse
|
||||
// cancellable provider work so owner shutdown interrupts that request.
|
||||
self.metadata_provider_work(&|| self.check(), |lease| {
|
||||
self.manager
|
||||
.preflight_auth(id, lease)
|
||||
.map(|_| "null".into())
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
environment
|
||||
.pending_auth(id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.map(|pending| serde_json::to_string(&pending).map_err(|error| error.to_string()))
|
||||
.transpose()
|
||||
.map(Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
/// Drop disposable resources while active requests retain their owner and IO.
|
||||
/// Memory pressure does not clear persisted lyrics, settings or credentials.
|
||||
pub fn release_memory(&self, under_pressure: bool) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
self.manager
|
||||
.release_idle_download_runtimes()
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.manager
|
||||
.environment()
|
||||
.cleanup_connections()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if under_pressure {
|
||||
self.cover.clear();
|
||||
self.lyrics
|
||||
.drop_memory()
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.health.clear_memory_cache();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown_checked(&self) -> Result<(), String> {
|
||||
let mut state = self.shutdown.lock().expect("backend shutdown lock");
|
||||
if let Some(result) = state.as_ref() {
|
||||
return result.clone();
|
||||
}
|
||||
self.closed.store(true, Ordering::Release);
|
||||
self.health.shutdown();
|
||||
self.musicbrainz.shutdown();
|
||||
self.manager.environment().shared_app_version().close();
|
||||
let result = self.lyrics.shutdown();
|
||||
let mut operations = self.operations.lock().expect("backend operations lock");
|
||||
while *operations != 0 {
|
||||
operations = self.idle.wait(operations).expect("backend operations wait");
|
||||
}
|
||||
drop(operations);
|
||||
self.share_cache.lock().expect("share cache lock").clear();
|
||||
self.cover.clear();
|
||||
self.prepared_downloads
|
||||
.lock()
|
||||
.expect("prepared downloads lock")
|
||||
.clear();
|
||||
if let Err(error) = &result {
|
||||
let _ = self.manager.environment().log_buffer().add(
|
||||
"ERROR",
|
||||
"Lyrics",
|
||||
&format!("Failed to persist lyrics cache: {error}"),
|
||||
);
|
||||
}
|
||||
self.availability.shutdown();
|
||||
self.manager.shutdown();
|
||||
*state = Some(result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
/// Preserve the native manager's void shutdown API; flush failures are
|
||||
/// logged, and native callers can also inspect `shutdown_checked`.
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_checked();
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), String> {
|
||||
if self.closed.load(Ordering::Acquire) || self.manager.environment().is_closed() {
|
||||
Err("backend is closed".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(&self) -> Result<Operation<'_>, String> {
|
||||
let mut operations = self.operations.lock().expect("backend operations lock");
|
||||
self.check()?;
|
||||
*operations += 1;
|
||||
Ok(Operation(self))
|
||||
}
|
||||
}
|
||||
|
||||
struct Operation<'a>(&'a Backend);
|
||||
|
||||
impl Drop for Operation<'_> {
|
||||
fn drop(&mut self) {
|
||||
*self.0.operations.lock().expect("backend operations lock") -= 1;
|
||||
self.0.idle.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Backend {
|
||||
type Target = ExtensionManager;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.manager
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Backend {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
use super::Backend;
|
||||
use cap_std::fs::OpenOptions;
|
||||
use spotiflac_core::app_version::AppVersion;
|
||||
use spotiflac_core::cover::{MAX_DOWNLOAD_BYTES, resize};
|
||||
use spotiflac_core::tags::{CoverArt, extract_cover};
|
||||
use spotiflac_network::{
|
||||
HttpRequest, NetworkService, NetworkSession, random_user_agent, url::UrlParts,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
type Check<'a> = dyn Fn() -> Result<(), String> + 'a;
|
||||
type Outcome = Result<Arc<[u8]>, String>;
|
||||
const CACHE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
const SIZE_ERROR: &str = "cover download exceeds 24 MiB limit";
|
||||
|
||||
struct Entry {
|
||||
data: Arc<[u8]>,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
entries: BTreeMap<String, Entry>,
|
||||
bytes: usize,
|
||||
flights: BTreeMap<String, Arc<Flight>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Flight {
|
||||
// Outer None is pending; inner None asks followers to retry after the
|
||||
// leader's cancellation. One caller cannot cancel another caller's work.
|
||||
outcome: Mutex<Option<Option<Outcome>>>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
pub(super) struct Cover {
|
||||
network: Arc<NetworkSession>,
|
||||
version: AppVersion,
|
||||
cache: Mutex<Cache>,
|
||||
}
|
||||
|
||||
impl Cover {
|
||||
pub fn new(network: &Arc<NetworkService>, version: AppVersion) -> Self {
|
||||
Self {
|
||||
network: network.native_media_session(Duration::from_secs(60)),
|
||||
version,
|
||||
cache: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
let mut cache = self.cache.lock().expect("cover cache lock");
|
||||
cache.entries.clear();
|
||||
cache.bytes = 0;
|
||||
}
|
||||
|
||||
pub(super) fn download(&self, url: &str, limit: i64, check: &Check<'_>) -> Outcome {
|
||||
let key = if limit <= 0 {
|
||||
url.to_owned()
|
||||
} else {
|
||||
format!("{url}\0max-dimension={limit}")
|
||||
};
|
||||
self.cached(&key, check, &|| {
|
||||
if limit <= 0 {
|
||||
return self.fetch(url, check);
|
||||
}
|
||||
let original = self.download(url, 0, check)?;
|
||||
match resize(&original, limit, check)
|
||||
.map_err(|error| format!("resize artwork: {error}"))?
|
||||
{
|
||||
std::borrow::Cow::Borrowed(_) => Ok(original),
|
||||
std::borrow::Cow::Owned(data) => Ok(data.into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cached(&self, key: &str, check: &Check<'_>, fetch: &dyn Fn() -> Outcome) -> Outcome {
|
||||
loop {
|
||||
check()?;
|
||||
let (flight, leader) = {
|
||||
let mut cache = self.cache.lock().expect("cover cache lock");
|
||||
if let Some(entry) = cache.entries.get(key) {
|
||||
if Instant::now() < entry.expires {
|
||||
return Ok(entry.data.clone());
|
||||
}
|
||||
let entry = cache.entries.remove(key).expect("expired cover entry");
|
||||
cache.bytes -= entry.data.len();
|
||||
}
|
||||
if let Some(flight) = cache.flights.get(key) {
|
||||
(flight.clone(), false)
|
||||
} else {
|
||||
let flight = Arc::new(Flight::default());
|
||||
cache.flights.insert(key.into(), flight.clone());
|
||||
(flight, true)
|
||||
}
|
||||
};
|
||||
if !leader {
|
||||
let mut outcome = flight.outcome.lock().expect("cover flight lock");
|
||||
loop {
|
||||
check()?;
|
||||
match outcome.as_ref() {
|
||||
Some(Some(result)) => return result.clone(),
|
||||
Some(None) => break,
|
||||
None => {
|
||||
outcome = flight
|
||||
.ready
|
||||
.wait_timeout(outcome, Duration::from_millis(25))
|
||||
.expect("cover flight wait")
|
||||
.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A decoder/transport panic must not strand coalesced callers.
|
||||
let result = catch_unwind(AssertUnwindSafe(fetch))
|
||||
.unwrap_or_else(|_| Err("cover fetch aborted".into()));
|
||||
let cancelled = check().err();
|
||||
let mut cache = self.cache.lock().expect("cover cache lock");
|
||||
if cancelled.is_none()
|
||||
&& let Ok(data) = &result
|
||||
&& !data.is_empty()
|
||||
&& data.len() <= MAX_DOWNLOAD_BYTES
|
||||
{
|
||||
cache.bytes += data.len();
|
||||
cache.entries.insert(
|
||||
key.into(),
|
||||
Entry {
|
||||
data: data.clone(),
|
||||
expires: Instant::now() + CACHE_TTL,
|
||||
},
|
||||
);
|
||||
while cache.bytes > MAX_DOWNLOAD_BYTES {
|
||||
let oldest = cache
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.expires)
|
||||
.map(|(key, _)| key.clone())
|
||||
.expect("nonempty cover cache");
|
||||
cache.bytes -= cache.entries.remove(&oldest).unwrap().data.len();
|
||||
}
|
||||
}
|
||||
cache.flights.remove(key);
|
||||
*flight.outcome.lock().expect("cover flight lock") =
|
||||
Some(cancelled.is_none().then(|| result.clone()));
|
||||
flight.ready.notify_all();
|
||||
return cancelled.map_or(result, Err);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(&self, url: &str, check: &Check<'_>) -> Outcome {
|
||||
let parsed = UrlParts::parse(url)
|
||||
.ok_or_else(|| "failed to create request: invalid URL".to_owned())?;
|
||||
let user_agent = if parsed.hostname.eq_ignore_ascii_case("api.zarz.moe") {
|
||||
self.version.user_agent()
|
||||
} else {
|
||||
random_user_agent()
|
||||
};
|
||||
let mut response = self
|
||||
.network
|
||||
.open_response_stream(
|
||||
HttpRequest {
|
||||
url: url.into(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers: BTreeMap::new(),
|
||||
default_json: false,
|
||||
user_agent,
|
||||
},
|
||||
check,
|
||||
)
|
||||
.map_err(|error| format!("failed to download cover: {error}"))?;
|
||||
if response.response.status != 200 {
|
||||
return Err(format!(
|
||||
"cover download failed: HTTP {}",
|
||||
response.response.status
|
||||
));
|
||||
}
|
||||
if response
|
||||
.response
|
||||
.headers
|
||||
.get("Content-Length")
|
||||
.and_then(|values| values.first())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.is_some_and(|length| length > MAX_DOWNLOAD_BYTES as u64)
|
||||
{
|
||||
return Err(SIZE_ERROR.into());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let capacity = buffer.len().min(MAX_DOWNLOAD_BYTES + 1 - data.len());
|
||||
let count = response
|
||||
.read(&mut buffer[..capacity], check)
|
||||
.map_err(|error| format!("failed to read cover data: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
data.extend_from_slice(&buffer[..count]);
|
||||
if data.len() > MAX_DOWNLOAD_BYTES {
|
||||
return Err(SIZE_ERROR.into());
|
||||
}
|
||||
}
|
||||
Ok(data.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn extract_cover_to_file(
|
||||
&self,
|
||||
audio_path: &str,
|
||||
output_path: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
let format = extension(audio_path);
|
||||
if !matches!(
|
||||
format.as_str(),
|
||||
"flac" | "m4a" | "aac" | "mp3" | "ogg" | "opus" | "wav" | "aiff" | "aif" | "aifc"
|
||||
) {
|
||||
return Err("unsupported audio format for cover extraction".into());
|
||||
}
|
||||
let files = self.manager.environment().native_files()?;
|
||||
let input = files.resolve_legacy(audio_path)?;
|
||||
let cover = read_cover(&input, &format, &check)
|
||||
.map_err(|error| format!("failed to extract cover: {error}"))?;
|
||||
let write = || {
|
||||
let output = files.resolve_legacy(output_path)?;
|
||||
if input.absolute == output.absolute {
|
||||
return Err("cover output must differ from the audio source".into());
|
||||
}
|
||||
let _lock = files.lock(&output, &check)?;
|
||||
let permissions = output_permissions(&output)?;
|
||||
publish_cover(&output, permissions, &cover.data, &check)
|
||||
};
|
||||
write().map_err(|error| format!("failed to write cover file: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_cover_to_cache_with_hint_and_key(
|
||||
&self,
|
||||
audio_path: &str,
|
||||
hint: &str,
|
||||
cache_directory: &str,
|
||||
explicit_key: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.cache_cover(
|
||||
audio_path,
|
||||
hint,
|
||||
cache_directory,
|
||||
explicit_key,
|
||||
None,
|
||||
read_cover,
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn save_cover_to_cache_for_scan(
|
||||
&self,
|
||||
audio_path: &str,
|
||||
hint: &str,
|
||||
cache_directory: &str,
|
||||
explicit_key: &str,
|
||||
read: impl FnOnce(&crate::files::FilePath, &str, &Check<'_>) -> Result<CoverArt, String>,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let format = spotiflac_core::tags::library_extension(audio_path, hint);
|
||||
// The scan's MP4-family reader also accepts .mp4/.aac. The standalone
|
||||
// cover export deliberately keeps its narrower legacy suffix policy.
|
||||
let format = matches!(format.as_str(), "mp4" | "aac").then_some("m4a");
|
||||
self.cache_cover(
|
||||
audio_path,
|
||||
hint,
|
||||
cache_directory,
|
||||
explicit_key,
|
||||
format,
|
||||
read,
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cache_cover(
|
||||
&self,
|
||||
audio_path: &str,
|
||||
hint: &str,
|
||||
cache_directory: &str,
|
||||
explicit_key: &str,
|
||||
format_override: Option<&str>,
|
||||
read: impl FnOnce(&crate::files::FilePath, &str, &Check<'_>) -> Result<CoverArt, String>,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
let files = self.manager.environment().native_files()?;
|
||||
let input = files.resolve_legacy(audio_path)?;
|
||||
input.native_display()?;
|
||||
let key = if !explicit_key.trim().is_empty() {
|
||||
explicit_key.trim().to_owned()
|
||||
} else if let Ok(metadata) = input.metadata() {
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_std();
|
||||
let nanos = match modified.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_nanos() as i128,
|
||||
Err(error) => -(error.duration().as_nanos() as i128),
|
||||
};
|
||||
format!("{audio_path}|{}|{nanos}", metadata.len())
|
||||
} else {
|
||||
audio_path.into()
|
||||
};
|
||||
let hash = key.chars().fold(5381_u32, |hash, value| {
|
||||
hash.wrapping_mul(33).wrapping_add(value as u32)
|
||||
});
|
||||
let path =
|
||||
|extension| Path::new(cache_directory).join(format!("cover_{hash:x}.{extension}"));
|
||||
let jpg = files.resolve_legacy(&path("jpg").to_string_lossy())?;
|
||||
let png = files.resolve_legacy(&path("png").to_string_lossy())?;
|
||||
for cached in [&jpg, &png] {
|
||||
cached.native_display()?;
|
||||
if cached.metadata().is_ok_and(|metadata| metadata.is_file()) {
|
||||
return Ok(cached.display());
|
||||
}
|
||||
}
|
||||
let mut format = extension(audio_path);
|
||||
if format.is_empty() {
|
||||
format = extension(hint);
|
||||
}
|
||||
if let Some(value) = format_override {
|
||||
format = value.into();
|
||||
}
|
||||
if !matches!(
|
||||
format.as_str(),
|
||||
"flac" | "m4a" | "mp3" | "ogg" | "opus" | "wav" | "aiff" | "aif" | "aifc"
|
||||
) {
|
||||
return Err(format!(
|
||||
"unsupported format: {}",
|
||||
if format.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(".{format}")
|
||||
}
|
||||
));
|
||||
}
|
||||
let mut cover = read(&input, &format, &check)?;
|
||||
if let Ok(std::borrow::Cow::Owned(resized)) = resize(
|
||||
&cover.data,
|
||||
spotiflac_core::cover::LIBRARY_MAX_DIMENSION,
|
||||
&check,
|
||||
) {
|
||||
cover.mime = if resized.starts_with(b"\x89PNG") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
}
|
||||
.into();
|
||||
cover.data = resized;
|
||||
}
|
||||
// Library caching may retain unsupported image bytes, but must never
|
||||
// swallow cancellation while attempting the optional resize.
|
||||
check()?;
|
||||
let output = if cover.mime.contains("png") { png } else { jpg };
|
||||
let _lock = files.lock(&output, &check)?;
|
||||
if output.metadata().is_ok_and(|metadata| metadata.is_file()) {
|
||||
return Ok(output.display());
|
||||
}
|
||||
output
|
||||
.mkdir_parent()
|
||||
.map_err(|error| format!("failed to create cache dir: {error}"))?;
|
||||
let permissions = output_permissions(&output)
|
||||
.map_err(|error| format!("failed to write cover: {error}"))?;
|
||||
publish_cover(&output, permissions, &cover.data, &check)
|
||||
.map_err(|error| format!("failed to write cover: {error}"))?;
|
||||
Ok(output.display())
|
||||
}
|
||||
|
||||
pub fn clear_cover_memory_cache(&self) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
self.cover.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_cover_to_file_sized(
|
||||
&self,
|
||||
url: &str,
|
||||
output_path: &str,
|
||||
max_dimension: i64,
|
||||
check: &Check<'_>,
|
||||
) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
if url.is_empty() {
|
||||
return Err("no cover URL provided".into());
|
||||
}
|
||||
let files = self.manager.environment().native_files()?;
|
||||
// Validate and lock before doing network work for an unauthorized or
|
||||
// unavailable destination. Publication revalidates the same grant.
|
||||
let prepare = || {
|
||||
let output = files.resolve_legacy(output_path)?;
|
||||
let lock = files.lock(&output, &check)?;
|
||||
let permissions = output_permissions(&output)?;
|
||||
Ok((output, lock, permissions))
|
||||
};
|
||||
let (output, _lock, permissions) =
|
||||
prepare().map_err(|error: String| format!("failed to write cover file: {error}"))?;
|
||||
let data = self
|
||||
.cover
|
||||
.download(url, max_dimension, &check)
|
||||
.map_err(|error| format!("failed to download cover: {error}"))?;
|
||||
publish_cover(&output, permissions, &data, &check)
|
||||
.map_err(|error| format!("failed to write cover file: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn read_cover(
|
||||
input: &crate::files::FilePath,
|
||||
format: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<CoverArt, String> {
|
||||
input.native_display()?;
|
||||
let mut file = input
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| error.to_string())?;
|
||||
extract_cover(&mut file, format, check)
|
||||
}
|
||||
|
||||
fn output_permissions(
|
||||
output: &crate::files::FilePath,
|
||||
) -> Result<Option<cap_std::fs::Permissions>, String> {
|
||||
output.native_display()?;
|
||||
output.require_parent()?;
|
||||
match output.open(OpenOptions::new().write(true)) {
|
||||
Ok(file) => Ok(Some(
|
||||
file.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.permissions(),
|
||||
)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_cover(
|
||||
output: &crate::files::FilePath,
|
||||
permissions: Option<cap_std::fs::Permissions>,
|
||||
data: &[u8],
|
||||
check: &Check<'_>,
|
||||
) -> Result<(), String> {
|
||||
check()?;
|
||||
let mut stage = output
|
||||
.stage_existing_parent()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some(permissions) = permissions {
|
||||
stage
|
||||
.file
|
||||
.set_permissions(permissions)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
for chunk in data.chunks(16 * 1024) {
|
||||
check()?;
|
||||
stage
|
||||
.file
|
||||
.write_all(chunk)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
stage.publish(check)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
fn cover() -> Cover {
|
||||
Cover::new(&NetworkService::new().unwrap(), "1".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_coalesces_and_a_cancelled_caller_does_not_cancel_its_peer() {
|
||||
for cancel_leader in [false, true] {
|
||||
let cover = cover();
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let (waiting_tx, waiting_rx) = mpsc::channel();
|
||||
let cancelled = AtomicBool::new(false);
|
||||
let calls = AtomicUsize::new(0);
|
||||
thread::scope(|scope| {
|
||||
let cover = &cover;
|
||||
let cancelled = &cancelled;
|
||||
let calls = &calls;
|
||||
let leader = scope.spawn(move || {
|
||||
cover.cached(
|
||||
"shared",
|
||||
&|| {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
Err("leader cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
&|| {
|
||||
calls.fetch_add(1, Ordering::AcqRel);
|
||||
entered_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
Ok(Arc::from(&b"leader"[..]))
|
||||
},
|
||||
)
|
||||
});
|
||||
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let follower = scope.spawn(|| {
|
||||
let checks = Cell::new(0);
|
||||
cover.cached(
|
||||
"shared",
|
||||
&|| {
|
||||
checks.set(checks.get() + 1);
|
||||
if checks.get() == 2 {
|
||||
waiting_tx.send(()).unwrap();
|
||||
if !cancel_leader {
|
||||
return Err("follower cancelled".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
&|| {
|
||||
calls.fetch_add(1, Ordering::AcqRel);
|
||||
Ok(Arc::from(&b"follower"[..]))
|
||||
},
|
||||
)
|
||||
});
|
||||
waiting_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
cancelled.store(cancel_leader, Ordering::Release);
|
||||
release_tx.send(()).unwrap();
|
||||
let (leader, follower) = (leader.join().unwrap(), follower.join().unwrap());
|
||||
let expected = if cancel_leader {
|
||||
assert_eq!(leader.unwrap_err(), "leader cancelled");
|
||||
assert_eq!(follower.unwrap().as_ref(), b"follower");
|
||||
assert_eq!(calls.load(Ordering::Acquire), 2);
|
||||
&b"follower"[..]
|
||||
} else {
|
||||
assert_eq!(leader.unwrap().as_ref(), b"leader");
|
||||
assert_eq!(follower.unwrap_err(), "follower cancelled");
|
||||
assert_eq!(calls.load(Ordering::Acquire), 1);
|
||||
&b"leader"[..]
|
||||
};
|
||||
assert_eq!(
|
||||
cover
|
||||
.cached("shared", &|| Ok(()), &|| panic!("cache miss"))
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
expected
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_expires_evicts_by_byte_budget_and_does_not_retain_failures() {
|
||||
let cover = cover();
|
||||
let fetches = Cell::new(0);
|
||||
let fetch = || {
|
||||
fetches.set(fetches.get() + 1);
|
||||
Ok(Arc::from(vec![42; 16 << 20]))
|
||||
};
|
||||
for key in ["first", "first", "second", "first"] {
|
||||
assert_eq!(
|
||||
cover.cached(key, &|| Ok(()), &fetch).unwrap().len(),
|
||||
16 << 20
|
||||
);
|
||||
}
|
||||
assert_eq!(fetches.get(), 3);
|
||||
cover
|
||||
.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut("first")
|
||||
.unwrap()
|
||||
.expires = Instant::now() - Duration::from_secs(1);
|
||||
cover.cached("first", &|| Ok(()), &fetch).unwrap();
|
||||
assert_eq!(fetches.get(), 4);
|
||||
cover.clear();
|
||||
cover.cached("first", &|| Ok(()), &fetch).unwrap();
|
||||
assert_eq!(fetches.get(), 5);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
cover
|
||||
.cached("error", &|| Ok(()), &|| Err("HTTP 500".into()))
|
||||
.unwrap_err(),
|
||||
"HTTP 500"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
cover
|
||||
.cached("panic", &|| Ok(()), &|| panic!("decoder panic"))
|
||||
.unwrap_err(),
|
||||
"cover fetch aborted"
|
||||
);
|
||||
assert_eq!(
|
||||
cover.cached("panic", &|| Ok(()), &fetch).unwrap().len(),
|
||||
16 << 20
|
||||
);
|
||||
assert_eq!(fetches.get(), 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
use super::*;
|
||||
use crate::environment::ExtensionEnvironment;
|
||||
use crate::{RuntimeLimits, backend::MetadataOptions};
|
||||
use spotiflac_network::{NetworkOptions, NetworkService};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
|
||||
const RECORDING: &str = r#"{"recordings":[{"tags":[{"name":"rock","count":1}],"releases":[{"title":"Album","artist-credit":[{"name":"Album Artist"}]}]}]}"#;
|
||||
const CATALOG: &str = r#"{"id":7,"album":{"id":9},"genres":{"data":[{"name":"Jazz"}]},"label":"Label","copyright":"Copyright"}"#;
|
||||
|
||||
fn network() -> (Arc<NetworkService>, Arc<rustls::ServerConfig>) {
|
||||
let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap();
|
||||
let config = rustls::ServerConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![certificate.cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()).into(),
|
||||
)
|
||||
.unwrap();
|
||||
let network = NetworkService::with_options(NetworkOptions {
|
||||
extra_root_pem: certificate.cert.pem().into_bytes(),
|
||||
doh_upstreams: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
network.set_allow_private_network(true);
|
||||
(network, Arc::new(config))
|
||||
}
|
||||
|
||||
fn server(
|
||||
requests: usize,
|
||||
body: &'static str,
|
||||
config: Arc<rustls::ServerConfig>,
|
||||
) -> (
|
||||
String,
|
||||
mpsc::Receiver<()>,
|
||||
mpsc::Sender<()>,
|
||||
thread::JoinHandle<Vec<String>>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("https://{}", listener.local_addr().unwrap());
|
||||
let (started, ready) = mpsc::channel();
|
||||
let (release, released) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut paths = Vec::new();
|
||||
for index in 0..requests {
|
||||
let stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(Instant::now() < deadline, "missing metadata request");
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
};
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut stream = rustls::StreamOwned::new(
|
||||
rustls::ServerConnection::new(Arc::clone(&config)).unwrap(),
|
||||
stream,
|
||||
);
|
||||
let mut request = [0; 4096];
|
||||
let count = stream.read(&mut request).unwrap();
|
||||
assert!(count > 0);
|
||||
paths.push(
|
||||
std::str::from_utf8(&request[..count])
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
);
|
||||
if index == 0 {
|
||||
let _ = started.send(());
|
||||
released.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
}
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
paths
|
||||
});
|
||||
(url, ready, release, worker)
|
||||
}
|
||||
|
||||
fn backend(
|
||||
directory: &Path,
|
||||
catalog: String,
|
||||
recording: String,
|
||||
network: Arc<NetworkService>,
|
||||
) -> Arc<Backend> {
|
||||
let environment = ExtensionEnvironment::with_network(
|
||||
&directory.join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
network,
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = MetadataOptions {
|
||||
deezer_endpoint: catalog,
|
||||
..Default::default()
|
||||
};
|
||||
options.musicbrainz.endpoint = recording;
|
||||
Arc::new(
|
||||
Backend::with_environment(
|
||||
&directory.join("sources"),
|
||||
environment,
|
||||
RuntimeLimits::default(),
|
||||
options,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_metadata_overlaps_requests_preserves_fields_and_reuses_genre_fallback() {
|
||||
for catalog_body in [
|
||||
CATALOG,
|
||||
r#"{"id":7,"album":{"id":9},"label":"Label","copyright":"Copyright"}"#,
|
||||
] {
|
||||
let (network, config) = network();
|
||||
let (catalog, catalog_ready, release_catalog, catalog_worker) =
|
||||
server(2, catalog_body, Arc::clone(&config));
|
||||
let (recording, recording_ready, release_recording, recording_worker) =
|
||||
server(1, RECORDING, config);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let backend = backend(directory.path(), catalog, recording, network);
|
||||
let active = Arc::clone(&backend);
|
||||
let worker = thread::spawn(move || {
|
||||
let mut request = DownloadRequest {
|
||||
isrc: "EXAMPLE12345".into(),
|
||||
album_name: "Album".into(),
|
||||
label: "Existing label".into(),
|
||||
..Default::default()
|
||||
};
|
||||
active
|
||||
.enrich_download_extended(&mut request, &|| Ok(()))
|
||||
.unwrap();
|
||||
request
|
||||
});
|
||||
// Neither server replies until both independent requests have started.
|
||||
catalog_ready.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
recording_ready
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
release_catalog.send(()).unwrap();
|
||||
release_recording.send(()).unwrap();
|
||||
let mut request = worker.join().unwrap();
|
||||
assert_eq!(request.album_artist, "Album Artist");
|
||||
assert_eq!(request.label, "Existing label");
|
||||
assert_eq!(request.copyright, "Copyright");
|
||||
assert_eq!(
|
||||
request.genre,
|
||||
if catalog_body == CATALOG {
|
||||
"Jazz"
|
||||
} else {
|
||||
"Rock"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_worker.join().unwrap(),
|
||||
["/2.0/track/isrc:EXAMPLE12345", "/2.0/album/9"]
|
||||
);
|
||||
recording_worker.join().unwrap();
|
||||
// Complete input must not query either server, which is now closed.
|
||||
backend
|
||||
.enrich_download_extended(&mut request, &|| Ok(()))
|
||||
.unwrap();
|
||||
assert_eq!(request.label, "Existing label");
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_metadata_cancellation_joins_both_blocked_requests() {
|
||||
for shutdown in [false, true] {
|
||||
let (network, config) = network();
|
||||
let (catalog, catalog_ready, release_catalog, catalog_worker) =
|
||||
server(1, CATALOG, Arc::clone(&config));
|
||||
let (recording, recording_ready, release_recording, recording_worker) =
|
||||
server(1, RECORDING, config);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let backend = backend(directory.path(), catalog, recording, network);
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stop = Arc::clone(&cancelled);
|
||||
let active = Arc::clone(&backend);
|
||||
let worker = thread::spawn(move || {
|
||||
let mut request = DownloadRequest {
|
||||
isrc: "EXAMPLE12345".into(),
|
||||
album_name: "Album".into(),
|
||||
..Default::default()
|
||||
};
|
||||
active.enrich_download_extended(&mut request, &|| {
|
||||
active.check()?;
|
||||
if stop.load(Ordering::Acquire) {
|
||||
Err("download cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
});
|
||||
catalog_ready.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
recording_ready
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
if shutdown {
|
||||
backend.shutdown();
|
||||
} else {
|
||||
cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
let started = Instant::now();
|
||||
while !worker.is_finished() {
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"cancel waited for HTTP replies"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let result = worker.join().unwrap();
|
||||
if shutdown {
|
||||
assert!(result.is_err(), "owner shutdown must cancel metadata");
|
||||
} else {
|
||||
assert_eq!(result, Err("download cancelled".into()));
|
||||
}
|
||||
release_catalog.send(()).unwrap();
|
||||
release_recording.send(()).unwrap();
|
||||
catalog_worker.join().unwrap();
|
||||
recording_worker.join().unwrap();
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
struct GatedServer {
|
||||
url: String,
|
||||
started: mpsc::Receiver<(String, mpsc::Sender<()>)>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<thread::JoinHandle<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl GatedServer {
|
||||
fn new(config: Arc<rustls::ServerConfig>, replies: &[(&str, u16, &str)]) -> Self {
|
||||
let replies: std::collections::BTreeMap<_, _> = replies
|
||||
.iter()
|
||||
.map(|(path, status, body)| (path.to_string(), (*status, body.to_string())))
|
||||
.collect();
|
||||
let replies = Arc::new(replies);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("https://{}", listener.local_addr().unwrap());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stopped = stop.clone();
|
||||
let (ready, started) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut workers = Vec::new();
|
||||
while !stopped.load(Ordering::Acquire) && Instant::now() < deadline {
|
||||
let stream = match listener.accept() {
|
||||
Ok((stream, _)) => stream,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
continue;
|
||||
}
|
||||
Err(error) => panic!("gated fixture accept: {error}"),
|
||||
};
|
||||
assert!(workers.len() < 8, "unexpected repeated request");
|
||||
let config = config.clone();
|
||||
let ready = ready.clone();
|
||||
let replies = replies.clone();
|
||||
workers.push(thread::spawn(move || {
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
let mut stream = rustls::StreamOwned::new(
|
||||
rustls::ServerConnection::new(config).unwrap(), stream,
|
||||
);
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
let mut bytes = [0; 4096];
|
||||
let count = stream.read(&mut bytes).unwrap();
|
||||
assert!(count > 0 && request.len() + count <= 8192);
|
||||
request.extend_from_slice(&bytes[..count]);
|
||||
}
|
||||
let path = std::str::from_utf8(&request).unwrap().split_whitespace().nth(1).unwrap()
|
||||
.split('?').next().unwrap().to_owned();
|
||||
let (release, released) = mpsc::channel();
|
||||
ready.send((path.clone(), release)).unwrap();
|
||||
let _ = released.recv_timeout(Duration::from_secs(3));
|
||||
let (status, body) = replies.get(&path).expect("unexpected fixture path");
|
||||
let _ = write!(stream, "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
path
|
||||
}));
|
||||
}
|
||||
workers
|
||||
.into_iter()
|
||||
.map(|worker| worker.join().unwrap())
|
||||
.collect()
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
started,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&self) -> (String, mpsc::Sender<()>) {
|
||||
self.started.recv_timeout(Duration::from_secs(2)).unwrap()
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Vec<String> {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let mut paths = self.worker.take().unwrap().join().unwrap();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GatedServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_search_gates_required_tracks_then_overlaps_optional_categories() {
|
||||
for mode in ["all", "artist", "track_failure"] {
|
||||
let (network, config) = network();
|
||||
let track = if mode == "track_failure" {
|
||||
r#"{"error":{"code":100,"message":"required track failure"}}"#
|
||||
} else {
|
||||
r#"{"data":[{"id":1,"title":"Track"}]}"#
|
||||
};
|
||||
let artist = if mode == "artist" {
|
||||
r#"{"data":[{"id":2,"name":"Artist"}]}"#
|
||||
} else {
|
||||
r#"{"error":{"code":100,"message":"optional artist failure"}}"#
|
||||
};
|
||||
let mut server = GatedServer::new(
|
||||
config,
|
||||
&[
|
||||
("/2.0/search/track", 200, track),
|
||||
("/2.0/search/artist", 200, artist),
|
||||
(
|
||||
"/2.0/search/album",
|
||||
200,
|
||||
r#"{"data":[{"id":3,"title":"Album"}]}"#,
|
||||
),
|
||||
(
|
||||
"/2.0/search/playlist",
|
||||
200,
|
||||
r#"{"data":[{"id":4,"title":"Playlist"}]}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let backend = backend(
|
||||
directory.path(),
|
||||
server.url.clone(),
|
||||
server.url.clone(),
|
||||
network,
|
||||
);
|
||||
let active = backend.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
active.search_deezer(
|
||||
"needle",
|
||||
2,
|
||||
2,
|
||||
if mode == "artist" { "artist" } else { "" },
|
||||
&|| Ok(()),
|
||||
)
|
||||
});
|
||||
let (first, release) = server.next();
|
||||
assert_eq!(
|
||||
first,
|
||||
if mode == "artist" {
|
||||
"/2.0/search/artist"
|
||||
} else {
|
||||
"/2.0/search/track"
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
server.started.recv_timeout(Duration::from_millis(100)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
));
|
||||
release.send(()).unwrap();
|
||||
if mode == "all" {
|
||||
let mut pending = Vec::new();
|
||||
for _ in 0..3 {
|
||||
pending.push(server.next());
|
||||
}
|
||||
let mut categories: Vec<_> = pending.iter().map(|(path, _)| path.as_str()).collect();
|
||||
categories.sort();
|
||||
assert_eq!(
|
||||
categories,
|
||||
[
|
||||
"/2.0/search/album",
|
||||
"/2.0/search/artist",
|
||||
"/2.0/search/playlist"
|
||||
]
|
||||
);
|
||||
for (_, release) in pending {
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
let result = worker.join().unwrap();
|
||||
if mode == "track_failure" {
|
||||
assert!(result.unwrap_err().contains("required track failure"));
|
||||
} else {
|
||||
let value: Value = serde_json::from_str(&result.unwrap()).unwrap();
|
||||
if mode == "all" {
|
||||
assert_eq!(value["tracks"][0]["spotify_id"], "deezer:1");
|
||||
assert_eq!(value["artists"], json!([]));
|
||||
assert_eq!(value["albums"][0]["id"], "deezer:3");
|
||||
assert_eq!(value["playlists"][0]["id"], "deezer:4");
|
||||
} else {
|
||||
assert_eq!(value["artists"][0]["id"], "deezer:2");
|
||||
assert_eq!(value["tracks"], json!([]));
|
||||
assert_eq!(value["albums"], json!([]));
|
||||
assert_eq!(value["playlists"], json!([]));
|
||||
}
|
||||
}
|
||||
let paths = server.finish();
|
||||
assert_eq!(paths.len(), if mode == "all" { 4 } else { 1 });
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
|
||||
for mode in ["success", "cover_failure", "lyrics_failure", "cancel"] {
|
||||
let (network, config) = network();
|
||||
let mut server = GatedServer::new(
|
||||
config,
|
||||
&[
|
||||
(
|
||||
"/cover",
|
||||
if mode == "cover_failure" { 404 } else { 200 },
|
||||
"artwork",
|
||||
),
|
||||
(
|
||||
"/lyrics",
|
||||
if mode == "lyrics_failure" { 404 } else { 200 },
|
||||
"New lyrics",
|
||||
),
|
||||
],
|
||||
);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let backend = backend(
|
||||
directory.path(),
|
||||
server.url.clone(),
|
||||
server.url.clone(),
|
||||
network,
|
||||
);
|
||||
let archive = directory.path().join("example.lyrics.sflx");
|
||||
let mut writer = zip::ZipWriter::new(std::fs::File::create(&archive).unwrap());
|
||||
let manifest = json!({"name":"example.lyrics","displayName":"Example Lyrics","version":"1",
|
||||
"description":"Generic lyrics fixture","type":["lyrics_provider"],"permissions":{"network":["127.0.0.1"]}});
|
||||
let source = format!(
|
||||
r#"registerExtension({{fetchLyrics(){{const response=http.get({});
|
||||
return response.statusCode===200 ? {{plainLyrics:response.body}} : {{}};}}}});"#,
|
||||
json!(format!("{}/lyrics", server.url))
|
||||
);
|
||||
for (name, body) in [
|
||||
("manifest.json", manifest.to_string()),
|
||||
("index.js", source),
|
||||
] {
|
||||
writer
|
||||
.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
writer.finish().unwrap();
|
||||
backend.install(&archive).unwrap();
|
||||
backend.set_enabled("example.lyrics", true).unwrap();
|
||||
backend
|
||||
.set_lyrics_providers_json(r#"["extension:example.lyrics"]"#)
|
||||
.unwrap();
|
||||
let audio = directory.path().join("data/track.mp3");
|
||||
let sidecar = directory.path().join("data/track.lrc");
|
||||
std::fs::write(&audio, "original audio").unwrap();
|
||||
std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap();
|
||||
let request = json!({"file_path":"track.mp3","track_name":"Track","artist_name":"Artist",
|
||||
"cover_url":format!("{}/cover", server.url),"embed_lyrics":true,"lyrics_mode":"both",
|
||||
"update_fields":["cover","lyrics"]})
|
||||
.to_string();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stop = cancelled.clone();
|
||||
let active = backend.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
active.reenrich_file(&request, &|| {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
Err("reenrich cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
});
|
||||
let first = server.next();
|
||||
let second = server.next();
|
||||
let mut paths = [first.0.as_str(), second.0.as_str()];
|
||||
paths.sort();
|
||||
assert_eq!(paths, ["/cover", "/lyrics"]);
|
||||
if mode == "cancel" {
|
||||
cancelled.store(true, Ordering::Release);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while !worker.is_finished() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"reenrich cancellation waited for server replies"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
} else {
|
||||
first.1.send(()).unwrap();
|
||||
second.1.send(()).unwrap();
|
||||
}
|
||||
let result = worker.join().unwrap();
|
||||
if mode == "cancel" {
|
||||
assert_eq!(result, Err("reenrich cancelled".into()));
|
||||
first.1.send(()).unwrap();
|
||||
second.1.send(()).unwrap();
|
||||
} else {
|
||||
let result: Value = serde_json::from_str(&result.unwrap()).unwrap();
|
||||
assert_eq!(result["method"], "ffmpeg");
|
||||
assert_eq!(result["write_external_lrc"], true);
|
||||
let lyrics = result["lyrics"].as_str().unwrap();
|
||||
assert!(lyrics.contains(if mode == "lyrics_failure" {
|
||||
"Old lyrics"
|
||||
} else {
|
||||
"New lyrics"
|
||||
}));
|
||||
assert_eq!(result["metadata"]["LYRICS"], lyrics);
|
||||
let cover = result["cover_path"].as_str().unwrap();
|
||||
if mode == "cover_failure" {
|
||||
assert!(cover.is_empty());
|
||||
} else {
|
||||
assert_eq!(std::fs::read(cover).unwrap(), b"artwork");
|
||||
std::fs::remove_file(cover).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&audio).unwrap(), b"original audio");
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics");
|
||||
assert!(
|
||||
std::fs::read_dir(directory.path().join("data"))
|
||||
.unwrap()
|
||||
.all(|entry| !entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("reenrich_cover_"))
|
||||
);
|
||||
assert_eq!(server.finish(), ["/cover", "/lyrics"]);
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,685 @@
|
||||
//! Health snapshots share the installed manager's revision and HTTP transport.
|
||||
|
||||
use super::Backend;
|
||||
use crate::manager::ExtensionManager;
|
||||
use crate::manifest::{ExtensionManifest, HealthCheck};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use spotiflac_core::app_version::AppVersion;
|
||||
use spotiflac_network::{
|
||||
HttpRequest, NetworkService, policy::NetworkPermissions, policy::private_literal_or_local,
|
||||
url::UrlParts,
|
||||
};
|
||||
use spotiflac_providers::resolver::Check;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ResultSnapshot {
|
||||
extension_id: String,
|
||||
status: String,
|
||||
checked_at: String,
|
||||
checks: Vec<CheckResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct CheckResult {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
label: String,
|
||||
url: String,
|
||||
method: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
service_key: String,
|
||||
required: bool,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
http_status: u16,
|
||||
latency_ms: u128,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
error: String,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
fn is_zero(value: &u16) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
revision: u64,
|
||||
result: ResultSnapshot,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
entries: BTreeMap<String, Cached>,
|
||||
refreshing: BTreeMap<String, u64>,
|
||||
workers: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub(super) struct Health {
|
||||
network: Arc<NetworkService>,
|
||||
version: AppVersion,
|
||||
state: Mutex<State>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
pub fn new(network: &Arc<NetworkService>, version: AppVersion) -> Self {
|
||||
Self {
|
||||
network: network.clone(),
|
||||
version,
|
||||
state: Mutex::default(),
|
||||
closed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_memory_cache(&self) {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("health state lock")
|
||||
.entries
|
||||
.clear();
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
let workers = {
|
||||
let mut state = self.state.lock().expect("health state lock");
|
||||
state.entries.clear();
|
||||
std::mem::take(&mut state.workers)
|
||||
};
|
||||
for worker in workers {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&self,
|
||||
manager: &ExtensionManager,
|
||||
manifest: &ExtensionManifest,
|
||||
revision: u64,
|
||||
check: &Check<'_>,
|
||||
) -> Result<ResultSnapshot, String> {
|
||||
let check = || {
|
||||
check()?;
|
||||
if self.closed.load(Ordering::Acquire) || manager.environment().is_closed() {
|
||||
return Err("health service closed".into());
|
||||
}
|
||||
if manager.metadata_revision() != revision {
|
||||
return Err("extension changed during health check".into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
check()?;
|
||||
let mut result = ResultSnapshot {
|
||||
extension_id: manifest.name.clone(),
|
||||
status: "unsupported".into(),
|
||||
checked_at: now(),
|
||||
checks: Vec::new(),
|
||||
};
|
||||
if manifest.service_health.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
result.status = "online".into();
|
||||
for health_check in &manifest.service_health {
|
||||
let entry = self.run_check(manifest, health_check, &check)?;
|
||||
result.status = match (
|
||||
entry.status.as_str(),
|
||||
entry.required,
|
||||
result.status.as_str(),
|
||||
) {
|
||||
("offline", true, _) => "offline",
|
||||
("offline", false, "online") | ("degraded", _, "online") => "degraded",
|
||||
("unknown", _, "online") => "unknown",
|
||||
_ => &result.status,
|
||||
}
|
||||
.into();
|
||||
result.checks.push(entry);
|
||||
}
|
||||
check()?;
|
||||
let ttl = manifest
|
||||
.service_health
|
||||
.iter()
|
||||
.filter(|check| check.cache_ttl_seconds > 0)
|
||||
.map(|check| (check.cache_ttl_seconds as u64).clamp(60, 600))
|
||||
.min()
|
||||
.unwrap_or(600);
|
||||
let ttl = if result.status == "unknown" {
|
||||
ttl.min(120)
|
||||
} else {
|
||||
ttl
|
||||
};
|
||||
let mut state = self.state.lock().expect("health state lock");
|
||||
check()?;
|
||||
state.entries.retain(|_, entry| entry.revision == revision);
|
||||
state.entries.insert(
|
||||
manifest.name.clone(),
|
||||
Cached {
|
||||
revision,
|
||||
result: result.clone(),
|
||||
expires: Instant::now() + Duration::from_secs(ttl),
|
||||
},
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn fallback_status(self: &Arc<Self>, manager: &Arc<ExtensionManager>, id: &str) -> String {
|
||||
let revision = manager.metadata_revision();
|
||||
let Ok(manifest) = manager.health_manifest(id) else {
|
||||
return "unknown".into();
|
||||
};
|
||||
if manifest.service_health.is_empty() {
|
||||
return "unknown".into();
|
||||
}
|
||||
let mut state = self.state.lock().expect("health state lock");
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return "unknown".into();
|
||||
}
|
||||
state.entries.retain(|_, entry| entry.revision == revision);
|
||||
let mut status = "unknown".to_owned();
|
||||
if let Some(entry) = state.entries.get(id) {
|
||||
if Instant::now() < entry.expires {
|
||||
return entry.result.status.clone();
|
||||
}
|
||||
if entry.result.status != "offline" {
|
||||
status.clone_from(&entry.result.status);
|
||||
}
|
||||
}
|
||||
if state.refreshing.get(id) == Some(&revision) {
|
||||
return status;
|
||||
}
|
||||
// Reap completed native threads, retaining handles for shutdown to join.
|
||||
let mut index = 0;
|
||||
while index < state.workers.len() {
|
||||
if state.workers[index].is_finished() {
|
||||
let _ = state.workers.swap_remove(index).join();
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
let health = self.clone();
|
||||
let manager = manager.clone();
|
||||
let id = id.to_owned();
|
||||
let running_id = id.clone();
|
||||
state.refreshing.insert(id.clone(), revision);
|
||||
match std::thread::Builder::new()
|
||||
.name("extension-health".into())
|
||||
.spawn(move || {
|
||||
let _ = health.fetch(&manager, &manifest, revision, &|| Ok(()));
|
||||
let mut state = health.state.lock().expect("health state lock");
|
||||
if state.refreshing.get(&running_id) == Some(&revision) {
|
||||
state.refreshing.remove(&running_id);
|
||||
}
|
||||
}) {
|
||||
Ok(worker) => state.workers.push(worker),
|
||||
Err(_) => {
|
||||
state.refreshing.remove(&id);
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn run_check(
|
||||
&self,
|
||||
manifest: &ExtensionManifest,
|
||||
input: &HealthCheck,
|
||||
check: &Check<'_>,
|
||||
) -> Result<CheckResult, String> {
|
||||
check()?;
|
||||
let mut method = input.method.trim().to_uppercase();
|
||||
if method.is_empty() {
|
||||
method = "GET".into();
|
||||
}
|
||||
let mut result = CheckResult {
|
||||
id: input.id.clone(),
|
||||
label: input.label.clone(),
|
||||
url: input.url.clone(),
|
||||
method: method.clone(),
|
||||
service_key: input.service_key.trim().into(),
|
||||
required: input.required,
|
||||
status: "unknown".into(),
|
||||
http_status: 0,
|
||||
latency_ms: 0,
|
||||
message: String::new(),
|
||||
error: String::new(),
|
||||
checked_at: now(),
|
||||
};
|
||||
let permissions = NetworkPermissions {
|
||||
domains: manifest.permissions.network.clone().unwrap_or_default(),
|
||||
allow_http: false,
|
||||
};
|
||||
let parsed = UrlParts::parse(&input.url);
|
||||
let invalid = match &parsed {
|
||||
None => Some("invalid health URL".to_owned()),
|
||||
Some(url) if url.scheme != "https" => Some("health check must use https".into()),
|
||||
Some(url) if url.hostname.is_empty() => {
|
||||
Some("health check URL hostname is required".into())
|
||||
}
|
||||
Some(url) if private_literal_or_local(&url.hostname) => {
|
||||
Some("private/local health check host is not allowed".into())
|
||||
}
|
||||
Some(url) if !permissions.allows_domain(&url.hostname) => Some(format!(
|
||||
"health check host '{}' is not in extension network permissions",
|
||||
url.hostname
|
||||
)),
|
||||
_ if method != "GET" && method != "HEAD" => {
|
||||
Some("health check method must be GET or HEAD".into())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(error) = invalid {
|
||||
result.status = "offline".into();
|
||||
result.error = error;
|
||||
return Ok(result);
|
||||
}
|
||||
let timeout = Duration::from_millis(if input.timeout_ms > 0 {
|
||||
input.timeout_ms as u64
|
||||
} else {
|
||||
4000
|
||||
});
|
||||
let start = Instant::now();
|
||||
let network_check = || {
|
||||
check()?;
|
||||
if start.elapsed() >= timeout {
|
||||
Err("HTTP request timeout exceeded".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let session = self.network.session(permissions, timeout);
|
||||
let response = session.open_response_stream(
|
||||
HttpRequest {
|
||||
url: input.url.clone(),
|
||||
method: method.clone(),
|
||||
body: String::new(),
|
||||
headers: BTreeMap::from([("Accept".into(), "application/json".into())]),
|
||||
default_json: false,
|
||||
user_agent: if parsed
|
||||
.as_ref()
|
||||
.is_some_and(|url| url.hostname.eq_ignore_ascii_case("api.zarz.moe"))
|
||||
{
|
||||
self.version.user_agent()
|
||||
} else {
|
||||
crate::utility_host::random_user_agent()
|
||||
},
|
||||
},
|
||||
network_check,
|
||||
);
|
||||
result.latency_ms = start.elapsed().as_millis();
|
||||
check()?;
|
||||
let mut response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
result.status = if transient_transport(&error) {
|
||||
"unknown"
|
||||
} else {
|
||||
"offline"
|
||||
}
|
||||
.into();
|
||||
result.error = error;
|
||||
return Ok(result);
|
||||
}
|
||||
};
|
||||
result.http_status = response.response.status;
|
||||
result.message = format!(
|
||||
"{} {}",
|
||||
response.response.status, response.response.status_text
|
||||
);
|
||||
if !(200..300).contains(&result.http_status) {
|
||||
result.status = "offline".into();
|
||||
return Ok(result);
|
||||
}
|
||||
if method == "HEAD" {
|
||||
result.status = "online".into();
|
||||
return Ok(result);
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
let mut buffer = [0; 8192];
|
||||
while body.len() < 64 * 1024 {
|
||||
let limit = buffer.len().min(64 * 1024 - body.len());
|
||||
match response.read(&mut buffer[..limit], network_check) {
|
||||
Ok(0) => break,
|
||||
Ok(count) => body.extend_from_slice(&buffer[..count]),
|
||||
Err(error) => {
|
||||
check()?;
|
||||
result.status = "degraded".into();
|
||||
result.error = error;
|
||||
result.message.clear();
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
let (status, message) = classify_body(&body, &input.service_key);
|
||||
result.status = status.into();
|
||||
if !message.is_empty() {
|
||||
result.message = message;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn check_extension_health_json(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let revision = self.manager.metadata_revision();
|
||||
let manifest = self
|
||||
.manager
|
||||
.health_manifest(id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let result = self.health.fetch(&self.manager, &manifest, revision, &|| {
|
||||
self.check().and_then(|()| check())
|
||||
})?;
|
||||
serde_json::to_string(&result).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn prioritize_healthy_downloads(
|
||||
&self,
|
||||
priority: Vec<String>,
|
||||
protected: &str,
|
||||
selected: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut groups = [Vec::new(), Vec::new(), Vec::new()];
|
||||
for id in priority {
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let status = if id.eq_ignore_ascii_case(protected)
|
||||
|| !self
|
||||
.fallback_allowed(id)
|
||||
.map_err(|error| error.to_string())?
|
||||
|| !self
|
||||
.download_manifest(id)
|
||||
.is_ok_and(|manifest| manifest.has_type("download_provider"))
|
||||
{
|
||||
"unknown".into()
|
||||
} else {
|
||||
self.health.fallback_status(&self.manager, id)
|
||||
};
|
||||
let index = match status.as_str() {
|
||||
"online" => 0,
|
||||
"degraded" => 1,
|
||||
"offline" => continue,
|
||||
_ => 2,
|
||||
};
|
||||
groups[index].push(id.to_owned());
|
||||
}
|
||||
let mut ordered: Vec<_> = groups.into_iter().flatten().collect();
|
||||
if let Some(index) = ordered
|
||||
.iter()
|
||||
.position(|id| id.eq_ignore_ascii_case(selected))
|
||||
{
|
||||
let id = ordered.remove(index);
|
||||
ordered.insert(0, id);
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
}
|
||||
|
||||
fn transient_message(value: &str) -> bool {
|
||||
let value = value.trim().to_lowercase();
|
||||
[
|
||||
"deadline exceeded",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"try again",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| value.contains(part))
|
||||
}
|
||||
|
||||
fn transient_transport(value: &str) -> bool {
|
||||
let value = value.to_lowercase();
|
||||
transient_message(&value)
|
||||
|| [
|
||||
"dns",
|
||||
"resolve",
|
||||
"connect",
|
||||
"unreachable",
|
||||
"certificate",
|
||||
"tls",
|
||||
"unexpected eof",
|
||||
"incomplete message",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| value.contains(part))
|
||||
}
|
||||
|
||||
fn classify_body(body: &[u8], key: &str) -> (&'static str, String) {
|
||||
let Ok(payload) = serde_json::from_slice::<Value>(body) else {
|
||||
return ("online", String::new());
|
||||
};
|
||||
let key = key.trim();
|
||||
if !key.is_empty()
|
||||
&& let Some(services) = payload["services"].as_object()
|
||||
{
|
||||
let Some(service) = services.get(key) else {
|
||||
return ("unknown", format!("service '{key}' not found"));
|
||||
};
|
||||
if !service.is_object() {
|
||||
return (
|
||||
"unknown",
|
||||
format!("service '{key}' has invalid health payload"),
|
||||
);
|
||||
}
|
||||
let text = |field: &str| service[field].as_str().unwrap_or_default();
|
||||
let message = [text("label"), text("detail"), text("error")]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ");
|
||||
let transient = [text("detail"), text("error"), text("label")]
|
||||
.into_iter()
|
||||
.any(transient_message);
|
||||
let ok = service["ok"].as_bool();
|
||||
let status = if let Some(code) = service["status"].as_f64() {
|
||||
let code = code as i64;
|
||||
if (200..300).contains(&code) || (code == 500 && ok == Some(true)) {
|
||||
"online"
|
||||
} else if matches!(code, 401 | 403) {
|
||||
"degraded"
|
||||
} else if transient || matches!(code, 408 | 429 | 502 | 503 | 504) {
|
||||
"unknown"
|
||||
} else {
|
||||
"offline"
|
||||
}
|
||||
} else if matches!(
|
||||
text("detail").trim().to_lowercase().as_str(),
|
||||
"auth_required" | "authorization_required" | "login_required" | "unauthorized"
|
||||
) {
|
||||
"degraded"
|
||||
} else if transient {
|
||||
"unknown"
|
||||
} else if let Some(ok) = ok {
|
||||
if ok { "online" } else { "offline" }
|
||||
} else {
|
||||
match text("status").trim().to_lowercase().as_str() {
|
||||
"ok" | "up" | "online" | "healthy" | "operational" => "online",
|
||||
"degraded" | "partial" | "warning" | "warn" => "degraded",
|
||||
"down" | "offline" | "error" | "failed" | "fail" | "unhealthy" => "offline",
|
||||
_ => "unknown",
|
||||
}
|
||||
};
|
||||
return (status, message);
|
||||
}
|
||||
let raw = payload["status"].as_str().unwrap_or_default();
|
||||
let status = match raw.trim().to_lowercase().as_str() {
|
||||
"degraded" | "partial" | "warning" | "warn" => "degraded",
|
||||
"down" | "offline" | "error" | "failed" | "fail" | "unhealthy" => {
|
||||
if transient_message(&String::from_utf8_lossy(body)) {
|
||||
"unknown"
|
||||
} else {
|
||||
"offline"
|
||||
}
|
||||
}
|
||||
_ => "online",
|
||||
};
|
||||
(status, raw.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::RuntimeLimits;
|
||||
use crate::backend::MetadataOptions;
|
||||
use crate::environment::ExtensionEnvironment;
|
||||
use serde_json::json;
|
||||
use spotiflac_network::{Lookup, LookupFuture, NetworkOptions};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
struct PendingLookup(Arc<AtomicUsize>);
|
||||
impl Lookup for PendingLookup {
|
||||
fn lookup(&self, _: &str) -> LookupFuture {
|
||||
self.0.fetch_add(1, Ordering::AcqRel);
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_ordering_recovers_stale_offline_coalesces_and_joins_refresh() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let lookups = Arc::new(AtomicUsize::new(0));
|
||||
let network = NetworkService::with_options(NetworkOptions {
|
||||
lookup: Arc::new(PendingLookup(lookups.clone())),
|
||||
doh_upstreams: Vec::new(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let environment = ExtensionEnvironment::with_network(
|
||||
&root.path().join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
network,
|
||||
)
|
||||
.unwrap();
|
||||
let backend = Backend::with_environment(
|
||||
&root.path().join("sources"),
|
||||
environment,
|
||||
RuntimeLimits::default(),
|
||||
MetadataOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let ids = [
|
||||
"example.selected",
|
||||
"example.offline",
|
||||
"example.unknown",
|
||||
"example.degraded",
|
||||
"example.online",
|
||||
];
|
||||
for id in ids {
|
||||
let source = root.path().join("sources").join(id);
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::write(source.join("manifest.json"), json!({"name":id,"version":"1","description":"Generic health fixture","type":["download_provider"],
|
||||
"permissions":{"network":["example.com"]},"serviceHealth":[{"id":"status","url":"https://example.com/status","required":true}]}).to_string()).unwrap();
|
||||
std::fs::write(source.join("index.js"), "registerExtension({});").unwrap();
|
||||
}
|
||||
backend.load_all().unwrap();
|
||||
for id in ids {
|
||||
backend.set_enabled(id, true).unwrap();
|
||||
}
|
||||
let revision = backend.metadata_revision();
|
||||
{
|
||||
let mut state = backend.health.state.lock().unwrap();
|
||||
for (id, status) in [
|
||||
(ids[0], "offline"),
|
||||
(ids[1], "offline"),
|
||||
(ids[3], "degraded"),
|
||||
(ids[4], "online"),
|
||||
] {
|
||||
state.entries.insert(
|
||||
id.into(),
|
||||
Cached {
|
||||
revision,
|
||||
expires: Instant::now() + Duration::from_secs(60),
|
||||
result: ResultSnapshot {
|
||||
extension_id: id.into(),
|
||||
status: status.into(),
|
||||
checked_at: now(),
|
||||
checks: Vec::new(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let priority = || ids.iter().map(|id| (*id).to_owned()).collect();
|
||||
let first = backend
|
||||
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
|
||||
.unwrap();
|
||||
assert_eq!(first, [ids[0], ids[4], ids[3], ids[2]]);
|
||||
backend
|
||||
.health
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut(ids[1])
|
||||
.unwrap()
|
||||
.expires = Instant::now() - Duration::from_secs(1);
|
||||
let started = Instant::now();
|
||||
for _ in 0..4 {
|
||||
let order = backend
|
||||
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
|
||||
.unwrap();
|
||||
assert_eq!(order, [ids[0], ids[4], ids[3], ids[1], ids[2]]);
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"fallback waited for unresolved health network work"
|
||||
);
|
||||
assert_eq!(backend.health.state.lock().unwrap().workers.len(), 2);
|
||||
while lookups.load(Ordering::Acquire) == 0 {
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let cached = backend.health.state.lock().unwrap().entries.len();
|
||||
assert!(cached > 0);
|
||||
backend.release_memory(false).unwrap();
|
||||
assert_eq!(backend.health.state.lock().unwrap().entries.len(), cached);
|
||||
backend.release_memory(true).unwrap();
|
||||
{
|
||||
let state = backend.health.state.lock().unwrap();
|
||||
assert!(state.entries.is_empty());
|
||||
assert_eq!(state.workers.len(), 2);
|
||||
assert!(state.workers.iter().all(|worker| !worker.is_finished()));
|
||||
}
|
||||
// A manager mutation invalidates all old snapshots and late refreshes.
|
||||
backend.set_enabled(ids[1], false).unwrap();
|
||||
backend.set_enabled(ids[1], true).unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
|
||||
.unwrap(),
|
||||
ids
|
||||
);
|
||||
let closed = Instant::now();
|
||||
backend.shutdown();
|
||||
assert!(
|
||||
closed.elapsed() < Duration::from_secs(1),
|
||||
"shutdown did not interrupt pending health DNS"
|
||||
);
|
||||
assert!(backend.health.state.lock().unwrap().workers.is_empty());
|
||||
assert!(backend.health.state.lock().unwrap().entries.is_empty());
|
||||
assert!(
|
||||
backend
|
||||
.check_extension_health_json(ids[0], &|| Ok(()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use super::Backend;
|
||||
use cap_std::fs::OpenOptions;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use serde_json::{Value, json};
|
||||
use spotiflac_core::cue::{self, CueSheet};
|
||||
use spotiflac_core::tags::{
|
||||
extract_cover, library_extension, library_id, library_metadata, read_library_metadata,
|
||||
read_library_metadata_with_cover,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
mod scan;
|
||||
pub(super) use scan::ScanState;
|
||||
|
||||
impl Backend {
|
||||
pub fn set_library_cover_cache_directory(&self, directory: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
if !directory.is_empty() {
|
||||
self.environment()
|
||||
.native_files()?
|
||||
.resolve_legacy(directory)?
|
||||
.native_display()?;
|
||||
}
|
||||
*self
|
||||
.library_cover_directory
|
||||
.lock()
|
||||
.expect("library cover directory lock") = directory.into();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_audio_metadata(
|
||||
&self,
|
||||
path: &str,
|
||||
hint: &str,
|
||||
cache_key: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
let files = self.environment().native_files()?;
|
||||
self.scan_audio_file(&files, path, hint, cache_key, &scan_time(), 0, &check)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_audio_file(
|
||||
&self,
|
||||
files: &crate::files::ExtensionFiles,
|
||||
path: &str,
|
||||
hint: &str,
|
||||
cache_key: &str,
|
||||
scan_time: &str,
|
||||
mod_time: i64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
check()?;
|
||||
let input = files.resolve_legacy(path)?;
|
||||
let mut file = match input.open_native_read() {
|
||||
Ok(file) => Some(file),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let mod_time = if mod_time > 0 {
|
||||
mod_time
|
||||
} else {
|
||||
modified(&input)
|
||||
};
|
||||
let mut metadata = None;
|
||||
let directory = self
|
||||
.library_cover_directory
|
||||
.lock()
|
||||
.expect("library cover directory lock")
|
||||
.clone();
|
||||
let cover = if directory.is_empty() {
|
||||
None
|
||||
} else {
|
||||
self.save_cover_to_cache_for_scan(
|
||||
path,
|
||||
hint,
|
||||
&directory,
|
||||
cache_key,
|
||||
|_, format, check| {
|
||||
let file = file.as_mut().ok_or("audio file unavailable")?;
|
||||
if format == "flac" {
|
||||
let (tags, cover) = read_library_metadata_with_cover(
|
||||
file, path, hint, scan_time, mod_time, check,
|
||||
)?;
|
||||
metadata = Some(tags);
|
||||
cover.ok_or_else(|| "no cover art found in file".into())
|
||||
} else {
|
||||
extract_cover(file, format, check)
|
||||
}
|
||||
},
|
||||
check,
|
||||
)
|
||||
.ok()
|
||||
};
|
||||
check()?;
|
||||
let mut result = match metadata {
|
||||
Some(metadata) => metadata,
|
||||
None => match file.as_mut() {
|
||||
Some(file) => read_library_metadata(file, path, hint, scan_time, mod_time, check)?,
|
||||
None => library_metadata(path, hint, scan_time, mod_time),
|
||||
},
|
||||
};
|
||||
if let Some(cover) = cover.filter(|cover| !cover.is_empty()) {
|
||||
result["coverPath"] = cover.into();
|
||||
}
|
||||
check()?;
|
||||
if result["hasLyrics"] == false {
|
||||
let open = |path: &str| {
|
||||
let input = files.resolve_legacy(path)?;
|
||||
input.open_native_read().map_err(|error| error.to_string())
|
||||
};
|
||||
if let Ok(lyrics) = spotiflac_core::lyrics::file::sidecar(path, &open, check) {
|
||||
result["hasLyrics"] =
|
||||
spotiflac_core::lyrics::lrc::has_usable_content(&lyrics).into();
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn parse_cue_file(
|
||||
&self,
|
||||
path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<CueSheet, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
let input = self.environment().native_files()?.resolve_legacy(path)?;
|
||||
input.native_display()?;
|
||||
let mut file = input
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| format!("failed to open cue file: {error}"))?;
|
||||
cue::parse(&mut file, &check)
|
||||
}
|
||||
|
||||
fn resolve_cue_audio(
|
||||
&self,
|
||||
path: &str,
|
||||
name: &str,
|
||||
audio_directory: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<String, String> {
|
||||
let base = Path::new(path).parent().unwrap_or(Path::new("."));
|
||||
let directory = if audio_directory.is_empty() {
|
||||
base
|
||||
} else {
|
||||
Path::new(audio_directory)
|
||||
};
|
||||
let files = self.environment().native_files()?;
|
||||
let exists = |name: &str| -> Result<Option<String>, String> {
|
||||
check()?;
|
||||
let candidate = crate::files::clean(&directory.join(name.trim_start_matches('/')));
|
||||
let candidate = candidate.to_string_lossy();
|
||||
let input = files.resolve_legacy(&candidate)?;
|
||||
input.native_display()?;
|
||||
Ok(input.metadata().is_ok().then(|| candidate.into_owned()))
|
||||
};
|
||||
if let Some(path) = exists(name)? {
|
||||
return Ok(path);
|
||||
}
|
||||
let suffix = library_extension(name, "");
|
||||
let stem = if suffix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
&name[..name.len() - suffix.len() - 1]
|
||||
};
|
||||
let extensions = [
|
||||
"flac", "wav", "aiff", "aif", "ape", "mp3", "ogg", "wv", "m4a",
|
||||
];
|
||||
for extension in extensions {
|
||||
for extension in [extension.to_owned(), extension.to_uppercase()] {
|
||||
if let Some(path) = exists(&format!("{stem}.{extension}"))? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let stem = Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default();
|
||||
for extension in extensions {
|
||||
if let Some(path) = exists(&format!("{stem}.{extension}"))? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
let directory_path = files.resolve_legacy(&directory.to_string_lossy())?;
|
||||
directory_path.native_display()?;
|
||||
if let Ok(entries) = directory_path.entries() {
|
||||
let mut candidates = entries.into_iter().filter(|(name, directory)| {
|
||||
!directory
|
||||
&& matches!(
|
||||
library_extension(name, "").as_str(),
|
||||
"flac" | "wav" | "ape" | "mp3" | "ogg" | "wv" | "m4a" | "aiff"
|
||||
)
|
||||
});
|
||||
if let Some((name, _)) = candidates.next()
|
||||
&& candidates.next().is_none()
|
||||
&& let Some(path) = exists(&name)?
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
Err(format!(
|
||||
"audio file not found for cue: {path} (referenced: {name})"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_cue_file_json(
|
||||
&self,
|
||||
path: &str,
|
||||
audio_directory: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
let sheet = self
|
||||
.parse_cue_file(path, &check)
|
||||
.map_err(|error| format!("failed to parse cue file: {error}"))?;
|
||||
let audio = self
|
||||
.resolve_cue_audio(path, &sheet.file_name, audio_directory, &check)
|
||||
.map_err(|error| {
|
||||
error.replace(
|
||||
"audio file not found for cue:",
|
||||
"audio file not found for cue sheet:",
|
||||
)
|
||||
})?;
|
||||
let tracks: Vec<Value> = sheet
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, track)| {
|
||||
let mut value = json!({"number":track.number,"title":track.title,
|
||||
"artist":prefer(&track.performer, &sheet.performer),"start_sec":track.start_time,
|
||||
"end_sec":next_start(&sheet, index).unwrap_or(-1.0)});
|
||||
optional(&mut value, "isrc", &track.isrc);
|
||||
optional(
|
||||
&mut value,
|
||||
"composer",
|
||||
prefer(&track.composer, &sheet.composer),
|
||||
);
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
check()?;
|
||||
let mut result = json!({"cue_path":path,"audio_path":audio,"album":sheet.title,"artist":sheet.performer,"tracks":tracks});
|
||||
optional(&mut result, "genre", &sheet.genre);
|
||||
optional(&mut result, "date", &sheet.date);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn scan_cue_file_for_library(
|
||||
&self,
|
||||
path: &str,
|
||||
audio_directory: &str,
|
||||
virtual_prefix: &str,
|
||||
mod_time: i64,
|
||||
cache_key: &str,
|
||||
scan_time: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
let sheet = self.parse_cue_file(path, &check)?;
|
||||
let audio = self.resolve_cue_audio(path, &sheet.file_name, audio_directory, &check)?;
|
||||
self.scan_cue_sheet(
|
||||
path,
|
||||
&sheet,
|
||||
&audio,
|
||||
virtual_prefix,
|
||||
mod_time,
|
||||
cache_key,
|
||||
scan_time,
|
||||
&check,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_cue_sheet(
|
||||
&self,
|
||||
path: &str,
|
||||
sheet: &CueSheet,
|
||||
audio: &str,
|
||||
virtual_prefix: &str,
|
||||
mod_time: i64,
|
||||
cache_key: &str,
|
||||
scan_time: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let files = self.environment().native_files()?;
|
||||
let input = files.resolve_legacy(audio)?;
|
||||
input.native_display()?;
|
||||
let format = library_extension(audio, "");
|
||||
let mut depth = 0;
|
||||
let mut rate = 0;
|
||||
let mut duration = 0.0;
|
||||
if let Ok(mut file) = input.open(OpenOptions::new().read(true)) {
|
||||
if format == "flac" {
|
||||
if let Ok(quality) = spotiflac_core::media::probe_quality(&mut file, check) {
|
||||
depth = quality.bit_depth;
|
||||
rate = quality.sample_rate;
|
||||
if rate > 0 && quality.total_samples > 0 {
|
||||
duration = quality.total_samples as f64 / rate as f64;
|
||||
}
|
||||
}
|
||||
} else if format == "mp3"
|
||||
&& let Ok(metadata) =
|
||||
spotiflac_core::tags::read_file_metadata(&mut file, audio, "", check)
|
||||
{
|
||||
rate = metadata["sample_rate"].as_i64().unwrap_or_default();
|
||||
duration = metadata["duration"].as_f64().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
let directory = self
|
||||
.library_cover_directory
|
||||
.lock()
|
||||
.expect("library cover directory lock")
|
||||
.clone();
|
||||
let cover = if directory.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.save_cover_to_cache_with_hint_and_key(audio, "", &directory, cache_key, check)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
check()?;
|
||||
let mod_time = if mod_time > 0 {
|
||||
mod_time
|
||||
} else {
|
||||
modified(&files.resolve_legacy(path)?)
|
||||
};
|
||||
let prefix = prefer(virtual_prefix, path);
|
||||
let mut results = Vec::with_capacity(sheet.tracks.len());
|
||||
for (index, track) in sheet.tracks.iter().enumerate() {
|
||||
check()?;
|
||||
let end = next_start(sheet, index).or_else(|| (duration > 0.0).then_some(duration));
|
||||
let mut result = json!({
|
||||
"id":library_id(&format!("{prefix}#track{}", track.number)),
|
||||
"filePath":format!("{prefix}#track{:02}", track.number),
|
||||
"trackName":if track.title.is_empty() { format!("Track {:02}", track.number) } else { track.title.clone() },
|
||||
"artistName":prefer(prefer(&track.performer, &sheet.performer), "Unknown Artist"),
|
||||
"albumName":prefer(&sheet.title, "Unknown Album"), "scannedAt":scan_time,
|
||||
"hasLyrics":false,"totalTracks":sheet.tracks.len(),"discNumber":1,"totalDiscs":1,
|
||||
"format":format!("cue+{format}"),
|
||||
});
|
||||
for (key, value) in [
|
||||
("fileModTime", mod_time),
|
||||
("trackNumber", track.number),
|
||||
(
|
||||
"duration",
|
||||
end.map_or(0, |end| (end - track.start_time) as i64),
|
||||
),
|
||||
("bitDepth", depth),
|
||||
("sampleRate", rate),
|
||||
] {
|
||||
if value != 0 {
|
||||
result[key] = value.into();
|
||||
}
|
||||
}
|
||||
for (key, value) in [
|
||||
("albumArtist", sheet.performer.as_str()),
|
||||
("coverPath", &cover),
|
||||
("isrc", &track.isrc),
|
||||
("releaseDate", &sheet.date),
|
||||
("genre", &sheet.genre),
|
||||
("composer", prefer(&track.composer, &sheet.composer)),
|
||||
] {
|
||||
optional(&mut result, key, value);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
Ok(results.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_time() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn modified(path: &crate::files::FilePath) -> i64 {
|
||||
path.metadata()
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.map(|time| match time.into_std().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_millis() as i64,
|
||||
Err(error) => -((error.duration().as_nanos().div_ceil(1_000_000)) as i64),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn prefer<'a>(value: &'a str, fallback: &'a str) -> &'a str {
|
||||
if value.is_empty() { fallback } else { value }
|
||||
}
|
||||
|
||||
fn optional(result: &mut Value, key: &str, value: &str) {
|
||||
if !value.is_empty() {
|
||||
result[key] = value.into();
|
||||
}
|
||||
}
|
||||
|
||||
fn next_start(sheet: &CueSheet, index: usize) -> Option<f64> {
|
||||
sheet.tracks.get(index + 1).map(|track| {
|
||||
if track.pre_gap >= 0.0 {
|
||||
track.pre_gap
|
||||
} else {
|
||||
track.start_time
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
//! One scan owner, bounded worker results and shared full/incremental traversal.
|
||||
|
||||
use super::{Backend, library_extension, modified, scan_time};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, TryLockError, mpsc};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::backend) struct ScanState {
|
||||
current: Mutex<Option<Arc<Run>>>,
|
||||
owner: Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
cancelled: AtomicBool,
|
||||
failure: Mutex<Option<String>>,
|
||||
progress: Mutex<Progress>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct Progress {
|
||||
total_files: usize,
|
||||
scanned_files: usize,
|
||||
current_file: String,
|
||||
error_count: usize,
|
||||
progress_pct: f64,
|
||||
is_complete: bool,
|
||||
}
|
||||
|
||||
struct AudioFile {
|
||||
path: String,
|
||||
modified: i64,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct ScanSummary {
|
||||
deleted: Vec<String>,
|
||||
skipped: usize,
|
||||
total: usize,
|
||||
run: Arc<Run>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn get_library_scan_progress(&self) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let current = self.library_scan.current.lock().expect("library scan lock");
|
||||
let value = match current.as_ref() {
|
||||
Some(run) => serde_json::to_value(&*run.progress.lock().expect("scan progress lock")),
|
||||
None => serde_json::to_value(Progress::default()),
|
||||
};
|
||||
value.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn cancel_library_scan(&self) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
if let Some(run) = self
|
||||
.library_scan
|
||||
.current
|
||||
.lock()
|
||||
.expect("library scan lock")
|
||||
.as_ref()
|
||||
{
|
||||
run.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_library_folder(&self, folder: &str, check: Check<'_>) -> Result<Value, String> {
|
||||
let mut tracks = Vec::new();
|
||||
self.scan_library(
|
||||
folder,
|
||||
None,
|
||||
true,
|
||||
&mut |value| {
|
||||
tracks.push(value);
|
||||
Ok(())
|
||||
},
|
||||
check,
|
||||
)?;
|
||||
Ok(tracks.into())
|
||||
}
|
||||
|
||||
pub fn scan_library_folder_incremental(
|
||||
&self,
|
||||
folder: &str,
|
||||
existing_json: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<Value, String> {
|
||||
// Go tolerates invalid snapshot JSON. A bad value does not discard other
|
||||
// valid paths from an otherwise well-formed object.
|
||||
let value: Value = serde_json::from_str(existing_json).unwrap_or(Value::Null);
|
||||
let existing = value
|
||||
.as_object()
|
||||
.map(|object| {
|
||||
object
|
||||
.iter()
|
||||
.filter_map(|(path, value)| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.is_null().then_some(0))
|
||||
.map(|value| (path.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.incremental(folder, existing, check)
|
||||
}
|
||||
|
||||
pub fn scan_library_folder_incremental_from_snapshot(
|
||||
&self,
|
||||
folder: &str,
|
||||
snapshot: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let mut existing = BTreeMap::new();
|
||||
if !snapshot.is_empty() {
|
||||
self.check()?;
|
||||
check()?;
|
||||
let input = self
|
||||
.environment()
|
||||
.native_files()?
|
||||
.resolve_legacy(snapshot)?;
|
||||
input.native_display()?;
|
||||
let file = input
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| format!("failed to load incremental snapshot: {error}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
self.check()?;
|
||||
check()?;
|
||||
line.clear();
|
||||
let read = (&mut reader)
|
||||
.take(64 * 1024)
|
||||
.read_until(b'\n', &mut line)
|
||||
.map_err(|error| format!("failed to load incremental snapshot: {error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if read == 64 * 1024 && line.last() != Some(&b'\n') {
|
||||
return Err(
|
||||
"failed to load incremental snapshot: bufio.Scanner: token too long".into(),
|
||||
);
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.last() == Some(&b'\r') {
|
||||
line.pop();
|
||||
}
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
if let Some((time, path)) = line.split_once('\t')
|
||||
&& let Ok(time) = time.parse::<i64>()
|
||||
{
|
||||
existing.insert(path.into(), time);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.incremental(folder, existing, check)
|
||||
}
|
||||
|
||||
fn incremental(
|
||||
&self,
|
||||
folder: &str,
|
||||
existing: BTreeMap<String, i64>,
|
||||
check: Check<'_>,
|
||||
) -> Result<Value, String> {
|
||||
let mut tracks = Vec::new();
|
||||
let summary = self.scan_library(
|
||||
folder,
|
||||
Some(&existing),
|
||||
true,
|
||||
&mut |value| {
|
||||
tracks.push(value);
|
||||
Ok(())
|
||||
},
|
||||
check,
|
||||
)?;
|
||||
Ok(
|
||||
json!({"scanned":tracks,"deletedPaths":if summary.deleted.is_empty() { Value::Null } else { json!(summary.deleted) },"skippedCount":summary.skipped,"totalFiles":summary.total}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn scan_library_folder_to_ndjson_file(
|
||||
&self,
|
||||
folder: &str,
|
||||
output: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<u64, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
if output.is_empty() {
|
||||
return Err("output path is empty".into());
|
||||
}
|
||||
if supported(output) {
|
||||
return Err("scan output must not overwrite an audio or CUE file".into());
|
||||
}
|
||||
let files = self.environment().native_files()?;
|
||||
let output = files.resolve_legacy(output)?;
|
||||
output.native_display()?;
|
||||
let _lock = files.lock(&output, &check)?;
|
||||
let mut stage = output
|
||||
.stage_existing_parent()
|
||||
.map_err(|error| format!("create scan output: {error}"))?;
|
||||
let mut count = 0;
|
||||
let summary;
|
||||
{
|
||||
let mut writer = BufWriter::with_capacity(64 * 1024, &mut stage.file);
|
||||
summary = self.scan_library(
|
||||
folder,
|
||||
None,
|
||||
false,
|
||||
&mut |value| {
|
||||
serde_json::to_writer(&mut writer, &value)
|
||||
.map_err(|error| error.to_string())?;
|
||||
writer.write_all(b"\n").map_err(|error| error.to_string())?;
|
||||
count += 1;
|
||||
Ok(())
|
||||
},
|
||||
&check,
|
||||
)?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("flush scan output: {error}"))?;
|
||||
}
|
||||
stage.publish(&|| {
|
||||
check()?;
|
||||
if summary.run.cancelled.load(Ordering::Acquire) {
|
||||
Err("scan cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn collect_library_files(
|
||||
&self,
|
||||
folder: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<Vec<AudioFile>, String> {
|
||||
let files = self.environment().native_files()?;
|
||||
let mut pending = vec![folder.to_owned()];
|
||||
let mut collected = Vec::new();
|
||||
while let Some(path) = pending.pop() {
|
||||
check()?;
|
||||
let input = files.resolve_legacy(&path)?;
|
||||
input.native_display()?;
|
||||
let metadata = input
|
||||
.metadata()
|
||||
.map_err(|error| format!("walk library path {path}: {error}"))?;
|
||||
if metadata.is_dir() {
|
||||
let entries = input
|
||||
.entries()
|
||||
.map_err(|error| format!("walk library path {path}: {error}"))?;
|
||||
for (name, directory) in entries.into_iter().rev() {
|
||||
if !directory && (!supported(&name) || staging(&name)) {
|
||||
continue;
|
||||
}
|
||||
pending.push(
|
||||
crate::files::clean(&Path::new(&path).join(name))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
} else if supported(&path) && !staging(&path) {
|
||||
collected.push(AudioFile {
|
||||
path,
|
||||
modified: modified(&input),
|
||||
size: metadata.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
fn scan_library(
|
||||
&self,
|
||||
folder: &str,
|
||||
existing: Option<&BTreeMap<String, i64>>,
|
||||
ordered: bool,
|
||||
sink: &mut dyn FnMut(Value) -> Result<(), String>,
|
||||
check: Check<'_>,
|
||||
) -> Result<ScanSummary, String> {
|
||||
let _operation = self.enter()?;
|
||||
check()?;
|
||||
if folder.is_empty() {
|
||||
return Err("folder path is empty".into());
|
||||
}
|
||||
// Retain the app data capability for this scan instead of reopening its
|
||||
// directory for every track. Path resolution still checks live grants.
|
||||
let files = self.environment().native_files()?;
|
||||
let input = files.resolve_legacy(folder)?;
|
||||
input.native_display()?;
|
||||
let metadata = input
|
||||
.metadata()
|
||||
.map_err(|error| format!("folder not found: {error}"))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(format!("path is not a folder: {folder}"));
|
||||
}
|
||||
let run = Arc::new(Run::default());
|
||||
if let Some(previous) = self
|
||||
.library_scan
|
||||
.current
|
||||
.lock()
|
||||
.expect("library scan lock")
|
||||
.replace(run.clone())
|
||||
{
|
||||
previous.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
let check = || {
|
||||
let mut failure = run.failure.lock().expect("scan failure lock");
|
||||
if let Some(error) = failure.as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let result = self.check().and_then(|()| {
|
||||
if run.cancelled.load(Ordering::Acquire) {
|
||||
Err("scan cancelled".into())
|
||||
} else {
|
||||
check()
|
||||
}
|
||||
});
|
||||
if let Err(error) = &result {
|
||||
*failure = Some(error.clone());
|
||||
}
|
||||
result
|
||||
};
|
||||
let _owner = loop {
|
||||
check()?;
|
||||
match self.library_scan.owner.try_lock() {
|
||||
Ok(owner) => break owner,
|
||||
Err(TryLockError::WouldBlock) => thread::sleep(Duration::from_millis(5)),
|
||||
Err(TryLockError::Poisoned(_)) => return Err("library scan owner poisoned".into()),
|
||||
}
|
||||
};
|
||||
let current = self.collect_library_files(folder, &check)?;
|
||||
let total = current.len();
|
||||
run.progress.lock().expect("scan progress lock").total_files = total;
|
||||
let paths: BTreeSet<_> = current.iter().map(|file| file.path.as_str()).collect();
|
||||
let mut cue_times = BTreeMap::new();
|
||||
let mut deleted = Vec::new();
|
||||
if let Some(existing) = existing {
|
||||
for (path, time) in existing {
|
||||
let base = cue_base(path);
|
||||
if let Some(base) = base {
|
||||
cue_times.entry(base).or_insert(*time);
|
||||
}
|
||||
if !paths.contains(base.unwrap_or(path)) {
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut skipped = 0;
|
||||
let tasks: Vec<_> = current
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
let old = existing
|
||||
.and_then(|existing| existing.get(&file.path))
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
(library_extension(&file.path, "") == "cue")
|
||||
.then(|| cue_times.get(file.path.as_str()).copied())
|
||||
.flatten()
|
||||
});
|
||||
if old == Some(file.modified) {
|
||||
skipped += 1;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let time = scan_time();
|
||||
let mut referenced = BTreeSet::new();
|
||||
let mut parsed = BTreeMap::new();
|
||||
// An unchanged CUE still owns its backing audio. Inspect all current
|
||||
// sheets so incremental scans never re-add that audio as a full album.
|
||||
for file in ¤t {
|
||||
check()?;
|
||||
if library_extension(&file.path, "") == "cue"
|
||||
&& let Ok(sheet) = self.parse_cue_file(&file.path, &check)
|
||||
&& !sheet.file_name.is_empty()
|
||||
&& let Ok(audio) = self.resolve_cue_audio(&file.path, &sheet.file_name, "", &check)
|
||||
{
|
||||
referenced.insert(audio.clone());
|
||||
parsed.insert(file.path.as_str(), (sheet, audio));
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
let mut results: BTreeMap<usize, Vec<Value>> = BTreeMap::new();
|
||||
let mut audio = Vec::new();
|
||||
let mut completed = skipped;
|
||||
for (index, file) in tasks.iter().enumerate() {
|
||||
check()?;
|
||||
if library_extension(&file.path, "") == "cue" {
|
||||
let result = match parsed.get(file.path.as_str()) {
|
||||
Some((sheet, audio)) => self.scan_cue_sheet(
|
||||
&file.path,
|
||||
sheet,
|
||||
audio,
|
||||
"",
|
||||
file.modified,
|
||||
"",
|
||||
&time,
|
||||
&check,
|
||||
),
|
||||
None => self.scan_cue_file_for_library(
|
||||
&file.path,
|
||||
"",
|
||||
"",
|
||||
file.modified,
|
||||
"",
|
||||
&time,
|
||||
&check,
|
||||
),
|
||||
}
|
||||
.map(|value| value.as_array().expect("cue track array").clone());
|
||||
check()?;
|
||||
completed += 1;
|
||||
update(&run, completed, &file.path, result.is_err());
|
||||
if let Ok(tracks) = result {
|
||||
if ordered {
|
||||
results.insert(index, tracks);
|
||||
} else {
|
||||
for value in tracks {
|
||||
sink(value)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if referenced.contains(&file.path) {
|
||||
completed += 1;
|
||||
update(&run, completed, &file.path, false);
|
||||
} else {
|
||||
audio.push((index, *file));
|
||||
}
|
||||
}
|
||||
// Allow short bursts without parking workers after each result. At most
|
||||
// 64 completed tracks wait; NDJSON still uses bounded memory.
|
||||
let workers = if audio.len() < 16 {
|
||||
1
|
||||
} else {
|
||||
thread::available_parallelism()
|
||||
.map_or(2, usize::from)
|
||||
.clamp(2, 4)
|
||||
};
|
||||
let next = AtomicUsize::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
thread::scope(|scope| -> Result<(), String> {
|
||||
let (sender, receiver) = mpsc::sync_channel(workers * 16);
|
||||
for _ in 0..workers {
|
||||
let sender = sender.clone();
|
||||
let (audio, next, stop, check, time, files) =
|
||||
(&audio, &next, &stop, &check, &time, &files);
|
||||
scope.spawn(move || {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let Some((index, file)) = audio.get(next.fetch_add(1, Ordering::AcqRel))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let result = check().and_then(|()| {
|
||||
self.scan_audio_file(
|
||||
files,
|
||||
&file.path,
|
||||
"",
|
||||
&format!("{}|{}|{}", file.path, file.size, file.modified),
|
||||
time,
|
||||
file.modified,
|
||||
check,
|
||||
)
|
||||
});
|
||||
if sender.send((*index, file.path.as_str(), result)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(sender);
|
||||
let mut failure = None;
|
||||
for (index, path, result) in receiver {
|
||||
completed += 1;
|
||||
update(&run, completed, path, result.is_err());
|
||||
if failure.is_none() {
|
||||
let emit = check().and_then(|()| {
|
||||
if let Ok(value) = result {
|
||||
if ordered {
|
||||
results.insert(index, vec![value]);
|
||||
} else {
|
||||
sink(value)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(error) = emit {
|
||||
failure = Some(error);
|
||||
stop.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
failure.map_or(Ok(()), Err)
|
||||
})?;
|
||||
check()?;
|
||||
for (_, values) in results {
|
||||
for value in values {
|
||||
check()?;
|
||||
sink(value)?;
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
// Share the existing duplicate index. A scan may observe edits/deletes
|
||||
// after a previously cached lookup; never retain that stale snapshot.
|
||||
if existing.is_none() || !tasks.is_empty() || !deleted.is_empty() {
|
||||
self.environment()
|
||||
.invalidate_isrc_cache(folder)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let mut progress = run.progress.lock().expect("scan progress lock");
|
||||
progress.scanned_files = total;
|
||||
progress.is_complete = true;
|
||||
progress.progress_pct = 100.0;
|
||||
drop(progress);
|
||||
Ok(ScanSummary {
|
||||
deleted,
|
||||
skipped,
|
||||
total,
|
||||
run,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn cue_base(path: &str) -> Option<&str> {
|
||||
path.rfind("#track")
|
||||
.filter(|index| *index > 0)
|
||||
.map(|index| &path[..index])
|
||||
}
|
||||
|
||||
fn supported(path: &str) -> bool {
|
||||
matches!(
|
||||
library_extension(path, "").as_str(),
|
||||
"flac"
|
||||
| "m4a"
|
||||
| "mp4"
|
||||
| "aac"
|
||||
| "mp3"
|
||||
| "opus"
|
||||
| "ogg"
|
||||
| "ape"
|
||||
| "wv"
|
||||
| "mpc"
|
||||
| "wav"
|
||||
| "aiff"
|
||||
| "aif"
|
||||
| "cue"
|
||||
)
|
||||
}
|
||||
|
||||
fn staging(path: &str) -> bool {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
name.ends_with(".partial")
|
||||
|| name
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(stem, _)| stem.ends_with(".partial"))
|
||||
}
|
||||
|
||||
fn update(run: &Run, completed: usize, path: &str, error: bool) {
|
||||
let mut progress = run.progress.lock().expect("scan progress lock");
|
||||
progress.scanned_files = completed;
|
||||
progress.current_file = Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
progress.error_count += usize::from(error);
|
||||
if progress.total_files > 0 {
|
||||
progress.progress_pct = completed as f64 / progress.total_files as f64 * 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use super::Backend;
|
||||
use crate::files::ExtensionFiles;
|
||||
use cap_std::fs::OpenOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spotiflac_core::lyrics::{LyricsResponse, config, file, lrc};
|
||||
use spotiflac_providers::lyrics::{Check, SearchRequest};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
/// Native export arguments. Unlike the private JS SDK, file_path is not trimmed.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LyricsRequest {
|
||||
pub spotify_id: String,
|
||||
pub track: String,
|
||||
pub artist: String,
|
||||
pub file_path: String,
|
||||
pub duration_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct LrcResult {
|
||||
lyrics: String,
|
||||
source: String,
|
||||
sync_type: String,
|
||||
instrumental: bool,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn embed_lyrics_to_file(
|
||||
&self,
|
||||
path: &str,
|
||||
lyrics: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let fields = BTreeMap::from([
|
||||
("LYRICS".into(), lyrics.into()),
|
||||
("UNSYNCEDLYRICS".into(), lyrics.into()),
|
||||
]);
|
||||
self.embed_flac_fields_response(
|
||||
path,
|
||||
&fields,
|
||||
"",
|
||||
("Failed to embed lyrics", "Lyrics embedded successfully"),
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_lyrics_lrc(
|
||||
&self,
|
||||
request: &LyricsRequest,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
Ok(self.lrc_result(request, &check)?.lyrics)
|
||||
}
|
||||
|
||||
pub fn get_lyrics_lrc_with_source(
|
||||
&self,
|
||||
request: &LyricsRequest,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
serde_json::to_string(&self.lrc_result(request, &check)?).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn lrc_result(&self, request: &LyricsRequest, check: &Check<'_>) -> Result<LrcResult, String> {
|
||||
check()?;
|
||||
if !request.file_path.is_empty() {
|
||||
let files = self.manager.environment().native_files()?;
|
||||
return Ok(match existing(&files, &request.file_path, check)? {
|
||||
Some(lyrics) => {
|
||||
let source = lrc::extract_source(&lyrics);
|
||||
LrcResult {
|
||||
source: if source.is_empty() {
|
||||
"Embedded".into()
|
||||
} else {
|
||||
source
|
||||
},
|
||||
sync_type: "EMBEDDED".into(),
|
||||
instrumental: lrc::is_instrumental_marker(&lyrics),
|
||||
lyrics,
|
||||
}
|
||||
}
|
||||
None => LrcResult::default(),
|
||||
});
|
||||
}
|
||||
let response = self.fetch_lyrics(request, check)?;
|
||||
Ok(LrcResult {
|
||||
lyrics: render(&response, request),
|
||||
source: response.source,
|
||||
sync_type: response.sync_type,
|
||||
instrumental: response.instrumental,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn fetch_lyrics(
|
||||
&self,
|
||||
request: &LyricsRequest,
|
||||
check: &Check<'_>,
|
||||
) -> Result<LyricsResponse, String> {
|
||||
let response = self
|
||||
.lyrics
|
||||
.fetch(
|
||||
SearchRequest {
|
||||
spotify_id: request.spotify_id.clone(),
|
||||
track: request.track.clone(),
|
||||
artist: request.artist.clone(),
|
||||
duration: request.duration_ms as f64 / 1000.0,
|
||||
..SearchRequest::default()
|
||||
},
|
||||
check,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn get_lyrics_providers_json(&self) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
serde_json::to_string(&self.lyrics.providers()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn set_lyrics_providers_json(&self, raw: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let providers = config::decode_providers(raw).map_err(|error| error.to_string())?;
|
||||
self.lyrics
|
||||
.set_providers(&providers)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_available_lyrics_providers_json(&self) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
serde_json::to_string(&config::available_providers()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_lyrics_fetch_options_json(&self) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
serde_json::to_string(&self.lyrics.options()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn set_lyrics_fetch_options_json(&self, raw: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let _settings = self.lyrics_settings.lock().expect("lyrics settings lock");
|
||||
let mut options = self.lyrics.options();
|
||||
options
|
||||
.update_json(raw)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.lyrics
|
||||
.set_options(options)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn fetch_and_save_lyrics(
|
||||
&self,
|
||||
request: &LyricsRequest,
|
||||
output_path: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
let files = self.manager.environment().native_files()?;
|
||||
let previous = if request.file_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
existing(&files, &request.file_path, &check)?
|
||||
};
|
||||
let (content, message) = if let Some(content) = previous {
|
||||
(
|
||||
content,
|
||||
format!("Saved LRC from embedded/sidecar to: {output_path}"),
|
||||
)
|
||||
} else {
|
||||
let response = self
|
||||
.fetch_lyrics(request, &check)
|
||||
.map_err(|error| format!("lyrics not found: {error}"))?;
|
||||
if response.instrumental {
|
||||
return Err("track is instrumental, no lyrics available".into());
|
||||
}
|
||||
let content = render(&response, request);
|
||||
if content.is_empty() {
|
||||
return Err("failed to generate LRC content".into());
|
||||
}
|
||||
let message = format!(
|
||||
"Saved LRC to: {output_path} ({} lines)",
|
||||
response.lines().len()
|
||||
);
|
||||
(content, message)
|
||||
};
|
||||
let write = || -> Result<(), String> {
|
||||
let output = files.resolve_legacy(output_path)?;
|
||||
let _output = files.lock(&output, &check)?;
|
||||
check()?;
|
||||
// Go WriteFile does not create missing parents. Reject symlinks and
|
||||
// non-regular destinations before an atomic replacement as well.
|
||||
output.native_display()?;
|
||||
output.require_parent()?;
|
||||
let permissions = match output.open(OpenOptions::new().write(true)) {
|
||||
Ok(file) => Some(
|
||||
file.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.permissions(),
|
||||
),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let mut stage = output
|
||||
.stage_existing_parent()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some(permissions) = permissions {
|
||||
stage
|
||||
.file
|
||||
.set_permissions(permissions)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
for chunk in content.as_bytes().chunks(16 * 1024) {
|
||||
check()?;
|
||||
stage
|
||||
.file
|
||||
.write_all(chunk)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
stage.publish(&check)
|
||||
};
|
||||
write().map_err(|error| format!("failed to write LRC file: {error}"))?;
|
||||
let _ = self
|
||||
.manager
|
||||
.environment()
|
||||
.log_buffer()
|
||||
.add("INFO", "Lyrics", &message);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn render(response: &LyricsResponse, request: &LyricsRequest) -> String {
|
||||
if response.instrumental {
|
||||
"[instrumental:true]".into()
|
||||
} else {
|
||||
lrc::with_metadata(response, &request.track, &request.artist)
|
||||
}
|
||||
}
|
||||
|
||||
fn existing(
|
||||
files: &ExtensionFiles,
|
||||
path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let result = file::extract(
|
||||
path,
|
||||
&|path| {
|
||||
files
|
||||
.resolve_legacy(path)?
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
check,
|
||||
);
|
||||
check()?;
|
||||
Ok(result.ok().filter(|text| lrc::has_usable_content(text)))
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use super::Backend;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use spotiflac_core::metadata::TrackMetadata;
|
||||
use spotiflac_providers::deezer::{self, MetadataLookup};
|
||||
use spotiflac_providers::musicbrainz::MusicBrainzOptions;
|
||||
use spotiflac_providers::resolver::{Check, ResolverError, ResolverOptions};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Trusted native configuration, never extension arguments or persisted settings.
|
||||
pub struct MetadataOptions {
|
||||
pub deezer_endpoint: String,
|
||||
pub resolver: ResolverOptions,
|
||||
pub musicbrainz: MusicBrainzOptions,
|
||||
}
|
||||
|
||||
impl Default for MetadataOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
deezer_endpoint: "https://api.deezer.com".into(),
|
||||
resolver: ResolverOptions::default(),
|
||||
musicbrainz: MusicBrainzOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(value: &impl Serialize) -> Result<String, ResolverError> {
|
||||
serde_json::to_string(value).map_err(|error| ResolverError::Failed(error.to_string()))
|
||||
}
|
||||
|
||||
fn context(error: ResolverError, prefix: &str) -> ResolverError {
|
||||
match error {
|
||||
ResolverError::Cancelled(_) | ResolverError::Closed | ResolverError::Busy => error,
|
||||
_ => ResolverError::Failed(format!("{prefix}: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn isrc_result(track: &TrackMetadata) -> Value {
|
||||
// Go's public map includes fields omitted by the ordinary track serializer.
|
||||
let mut result = json!({
|
||||
"spotify_id": track.spotify_id, "artists": track.artists, "name": track.name,
|
||||
"album_name": track.album_name, "album_artist": track.album_artist,
|
||||
"duration_ms": track.duration_ms, "images": track.images,
|
||||
"release_date": track.release_date, "track_number": track.track_number,
|
||||
"total_tracks": track.total_tracks, "disc_number": track.disc_number,
|
||||
"total_discs": track.total_discs, "external_urls": track.external_urls,
|
||||
"isrc": track.isrc, "album_id": track.album_id, "artist_id": track.artist_id,
|
||||
"album_type": track.album_type, "composer": track.composer
|
||||
});
|
||||
let id = track
|
||||
.spotify_id
|
||||
.strip_prefix("deezer:")
|
||||
.unwrap_or(&track.spotify_id)
|
||||
.trim();
|
||||
if !id.is_empty() {
|
||||
result["id"] = json!(id);
|
||||
result["track_id"] = json!(id);
|
||||
result["success"] = json!(true);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub(super) fn metadata_operation<T>(
|
||||
&self,
|
||||
seconds: u64,
|
||||
check: &Check<'_>,
|
||||
work: impl FnOnce(&Check<'_>) -> Result<T, ResolverError>,
|
||||
) -> Result<T, String> {
|
||||
let _operation = self.enter()?;
|
||||
let started = Instant::now();
|
||||
let check = || {
|
||||
self.check()?;
|
||||
check()?;
|
||||
if started.elapsed() >= Duration::from_secs(seconds) {
|
||||
Err("context deadline exceeded".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check()?;
|
||||
let result = work(&check);
|
||||
check()?;
|
||||
result.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn set_metadata_language(&self, tag: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
self.deezer.set_language(tag);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_song_link_region(&self, region: &str) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
self.availability
|
||||
.set_region(region)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_song_link_region(&self) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
self.availability
|
||||
.region()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
// The retired Settings track-ID cache is distinct from catalog caches.
|
||||
pub fn get_track_cache_size(&self) -> Result<u64, String> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
pub fn clear_track_id_cache(&self) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_deezer_url(&self, url: &str) -> Result<(String, String), String> {
|
||||
let _operation = self.enter()?;
|
||||
deezer::parse_url(url).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn search_deezer(
|
||||
&self,
|
||||
query: &str,
|
||||
track_limit: isize,
|
||||
artist_limit: isize,
|
||||
filter: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(
|
||||
self.deezer
|
||||
.search_all(query, track_limit, artist_limit, filter, check)?
|
||||
.as_ref(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_deezer_metadata(
|
||||
&self,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| self.deezer_metadata(kind, id, check))
|
||||
}
|
||||
|
||||
pub(super) fn deezer_metadata(
|
||||
&self,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, ResolverError> {
|
||||
match kind {
|
||||
"track" => encode(&json!({"track": self.deezer.get_track(id, check)?})),
|
||||
"album" => encode(self.deezer.get_album(id, check)?.as_ref()),
|
||||
"artist" => encode(self.deezer.get_artist(id, check)?.as_ref()),
|
||||
"playlist" => encode(&self.deezer.get_playlist(id, check)?),
|
||||
_ => Err(ResolverError::Failed(format!(
|
||||
"unsupported Deezer resource type: {kind}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_deezer_extended_metadata(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(15, check, |check| {
|
||||
if id.is_empty() { return Err(ResolverError::Failed("empty track ID".into())); }
|
||||
let metadata = self.deezer.get_extended_metadata_by_track_id(id, check)?;
|
||||
encode(&json!({"genre":metadata.genre,"label":metadata.label,"copyright":metadata.copyright}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn search_deezer_by_isrc(&self, isrc: &str, check: &Check<'_>) -> Result<String, String> {
|
||||
self.search_deezer_by_isrc_for_item_id(isrc, "", check)
|
||||
}
|
||||
|
||||
pub fn search_deezer_by_isrc_for_item_id(
|
||||
&self,
|
||||
isrc: &str,
|
||||
item_id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(10, check, |check| {
|
||||
let state = self.manager.environment().download_state();
|
||||
let lease = if item_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
state
|
||||
.cancellation
|
||||
.acquire(item_id)
|
||||
.map_err(|error| ResolverError::Cancelled(error.to_string()))?,
|
||||
)
|
||||
};
|
||||
let check = || {
|
||||
check()?;
|
||||
lease.as_ref().map_or(Ok(()), |lease| {
|
||||
lease.check_active().map_err(|error| error.to_string())
|
||||
})
|
||||
};
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
let track = self.deezer.search_by_isrc(isrc, &check);
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
encode(&isrc_result(&track?))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_spotify_to_deezer(
|
||||
&self,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
let (deezer_id, fetch_error) = match kind {
|
||||
"track" => (self.deezer_id_from_spotify(id, check).map_err(|error| context(error, "could not find Deezer equivalent"))?, "failed to fetch Deezer metadata"),
|
||||
"album" => (self.availability.deezer_album_id(id, check).map_err(|error| context(error, "could not find Deezer album"))?, "failed to fetch Deezer album metadata"),
|
||||
_ => return Err(ResolverError::Failed(format!("spotify to Deezer conversion only supported for tracks and albums: please search by name for {kind}"))),
|
||||
};
|
||||
self.deezer_metadata(kind, &deezer_id, check).map_err(|error| context(error, fetch_error))
|
||||
})
|
||||
}
|
||||
|
||||
fn deezer_id_from_spotify(&self, id: &str, check: &Check<'_>) -> Result<String, ResolverError> {
|
||||
let track = self.availability.check_track(id, "", check)?;
|
||||
if track.deezer && !track.deezer_id.is_empty() {
|
||||
Ok(track.deezer_id)
|
||||
} else {
|
||||
Err(ResolverError::Failed("track not found on Deezer".into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_track_platform_links_json(
|
||||
&self,
|
||||
id: &str,
|
||||
isrc: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&json!({"platforms":self.availability.track_platform_links(id, isrc, check)?}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_track_availability_json(
|
||||
&self,
|
||||
id: &str,
|
||||
isrc: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&self.availability.check_track(id, isrc, check)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_album_availability_json(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&self.availability.check_album(id, check)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_availability_from_deezer_json(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&self.availability.check_from_deezer(id, check)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_availability_by_platform_json(
|
||||
&self,
|
||||
platform: &str,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(
|
||||
&self
|
||||
.availability
|
||||
.check_by_platform(platform, kind, id, check)?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_availability_from_url_json(
|
||||
&self,
|
||||
url: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&self.availability.check_from_url(url, check)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_streaming_urls_json(&self, id: &str, check: &Check<'_>) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
encode(&self.availability.streaming_urls(id, check)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_deezer_id_from_spotify(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| self.deezer_id_from_spotify(id, check))
|
||||
}
|
||||
|
||||
pub fn get_deezer_album_id_from_spotify(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.deezer_album_id(id, check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_youtube_url_from_spotify(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.youtube_url_from_spotify(id, check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_spotify_id_from_deezer_track(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.platform_from_deezer(id, "spotify", check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tidal_url_from_deezer_track(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.platform_from_deezer(id, "tidal", check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_amazon_url_from_deezer_track(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.platform_from_deezer(id, "amazon", check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_youtube_url_from_deezer_track(
|
||||
&self,
|
||||
id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
self.availability.platform_from_deezer(id, "youtube", check)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::RuntimeLimits;
|
||||
use crate::environment::ExtensionEnvironment;
|
||||
use spotiflac_network::{NetworkOptions, NetworkService};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn isrc_http_cancellation_does_not_cancel_another_item() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap();
|
||||
let config = Arc::new(
|
||||
rustls::ServerConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![certificate.cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||
certificate.signing_key.serialize_der(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let network = NetworkService::with_options(NetworkOptions {
|
||||
extra_root_pem: certificate.cert.pem().into_bytes(),
|
||||
doh_upstreams: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
network.set_allow_private_network(true);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let environment = ExtensionEnvironment::with_network(
|
||||
&root.path().join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
network,
|
||||
)
|
||||
.unwrap();
|
||||
let backend = Arc::new(
|
||||
Backend::with_environment(
|
||||
&root.path().join("sources"),
|
||||
environment,
|
||||
RuntimeLimits::default(),
|
||||
MetadataOptions {
|
||||
deezer_endpoint: format!("https://{}", listener.local_addr().unwrap()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let start = |isrc: &'static str, item: &'static str| {
|
||||
let owner = backend.clone();
|
||||
let (finished, completion) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
finished
|
||||
.send(owner.search_deezer_by_isrc_for_item_id(isrc, item, &|| Ok(())))
|
||||
.unwrap();
|
||||
});
|
||||
(worker, completion)
|
||||
};
|
||||
let accept = |isrc: &str| {
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error)
|
||||
if error.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
|
||||
{
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(error) => panic!("ISRC request did not arrive: {error}"),
|
||||
}
|
||||
};
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut stream = rustls::StreamOwned::new(
|
||||
rustls::ServerConnection::new(config.clone()).unwrap(),
|
||||
stream,
|
||||
);
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert!(line.starts_with(&format!("GET /2.0/track/isrc:{isrc} ")));
|
||||
loop {
|
||||
line.clear();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stream
|
||||
};
|
||||
let (cancelled_worker, cancelled) = start("USAAA2600001", "item-a");
|
||||
let cancelled_stream = accept("USAAA2600001");
|
||||
let (surviving_worker, surviving) = start("USAAA2600002", "item-b");
|
||||
let mut surviving_stream = accept("USAAA2600002");
|
||||
|
||||
backend.release_memory(true).unwrap();
|
||||
assert!(cancelled.try_recv().is_err());
|
||||
assert!(surviving.try_recv().is_err());
|
||||
backend
|
||||
.environment()
|
||||
.download_state()
|
||||
.cancel("item-a")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cancelled.recv_timeout(Duration::from_secs(3)).unwrap(),
|
||||
Err("download cancelled".into())
|
||||
);
|
||||
assert!(surviving.try_recv().is_err());
|
||||
// Neither response has been sent: cancellation must interrupt active IO.
|
||||
drop(cancelled_stream);
|
||||
let body = r#"{"id":7,"title":"Survivor","duration":3,"isrc":"USAAA2600002","artist":{"id":2,"name":"Artist"},"album":{"id":3,"title":"Album"}}"#;
|
||||
write!(
|
||||
surviving_stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
surviving_stream.flush().unwrap();
|
||||
drop(surviving_stream);
|
||||
let result = surviving
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(result["success"], true);
|
||||
assert_eq!(result["track_id"], "7");
|
||||
assert_eq!(result["isrc"], "USAAA2600002");
|
||||
cancelled_worker.join().unwrap();
|
||||
surviving_worker.join().unwrap();
|
||||
backend.shutdown_checked().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_cancels_metadata_and_keeps_directory_ownership_until_work_releases() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let create = || {
|
||||
Backend::new(
|
||||
&root.path().join("sources"),
|
||||
&root.path().join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let backend = Arc::new(create());
|
||||
let availability = backend.availability();
|
||||
let (entered, started) = mpsc::channel();
|
||||
let (cancelled, cancellation) = mpsc::channel();
|
||||
let (release, released) = mpsc::channel();
|
||||
let (finished, completion) = mpsc::channel();
|
||||
let owner = backend.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
owner.metadata_operation(30, &|| Ok(()), |check| -> Result<(), ResolverError> {
|
||||
entered.send(()).unwrap();
|
||||
loop {
|
||||
if let Err(error) = check() {
|
||||
cancelled.send(()).unwrap();
|
||||
released.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
return Err(ResolverError::Cancelled(error));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
})
|
||||
});
|
||||
started.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
let owner = backend.clone();
|
||||
let shutdown = thread::spawn(move || {
|
||||
owner.shutdown_checked().unwrap();
|
||||
finished.send(()).unwrap();
|
||||
});
|
||||
cancellation.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert!(completion.try_recv().is_err());
|
||||
assert!(
|
||||
Backend::new(
|
||||
&root.path().join("sources"),
|
||||
&root.path().join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
RuntimeLimits::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(worker.join().unwrap(), Err("backend is closed".into()));
|
||||
shutdown.join().unwrap();
|
||||
completion.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert!(availability.region().is_err());
|
||||
assert_eq!(
|
||||
backend.get_track_cache_size(),
|
||||
Err("backend is closed".into())
|
||||
);
|
||||
assert_eq!(create().get_track_cache_size().unwrap(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use super::Backend;
|
||||
use spotiflac_providers::resolver::Check;
|
||||
|
||||
impl Backend {
|
||||
pub fn fetch_music_brainz_genre_by_isrc(
|
||||
&self,
|
||||
isrc: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
// Three 10-second HTTP attempts plus two 2-second retry waits fit
|
||||
// within this root deadline. Cancellation still applies per caller.
|
||||
self.metadata_operation(35, check, |check| self.musicbrainz.genre(isrc, check))
|
||||
}
|
||||
|
||||
pub fn fetch_music_brainz_album_artist_by_isrc(
|
||||
&self,
|
||||
isrc: &str,
|
||||
album_name: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(35, check, |check| {
|
||||
self.musicbrainz.album_artist(isrc, album_name, check)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
use super::{Backend, ManagerError};
|
||||
use serde_json::{Map, Value, json};
|
||||
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry, RequestLease};
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use spotiflac_providers::resolver::{Check, ResolverError};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
impl Backend {
|
||||
pub fn enrich_track_json(&self, id: &str, track_json: &str) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
self.manager
|
||||
.enrich_track_export(id, track_json)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn handle_url_json(&self, url: &str) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let id = self
|
||||
.manager
|
||||
.find_url_handler(url)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| format!("no extension found to handle URL: {url}"))?;
|
||||
let raw = self
|
||||
.manager
|
||||
.provider_call(&id, "handleUrl", &json!([url]).to_string(), None, 30_000)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
|
||||
let mut result = strings(
|
||||
&value,
|
||||
&[
|
||||
"type",
|
||||
"id",
|
||||
"name",
|
||||
"cover_url",
|
||||
"header_image",
|
||||
"header_video",
|
||||
],
|
||||
);
|
||||
result.insert("extension_id".into(), id.into());
|
||||
if let Some(value) = value.get("track").filter(|value| !value.is_null()) {
|
||||
result.insert("track".into(), track(value, "", 0));
|
||||
}
|
||||
let values = array(&value, "tracks");
|
||||
if !values.is_empty() {
|
||||
result.insert("tracks".into(), url_tracks(values));
|
||||
}
|
||||
if let Some(value) = value.get("album").filter(|value| !value.is_null()) {
|
||||
let mut info = strings(
|
||||
value,
|
||||
&[
|
||||
"id",
|
||||
"name",
|
||||
"artists",
|
||||
"cover_url",
|
||||
"header_image",
|
||||
"header_video",
|
||||
"release_date",
|
||||
"album_type",
|
||||
"provider_id",
|
||||
],
|
||||
);
|
||||
info.insert("audio_traits".into(), value["audio_traits"].clone());
|
||||
info.insert(
|
||||
"total_tracks".into(),
|
||||
json!(value["total_tracks"].as_i64().unwrap_or_default()),
|
||||
);
|
||||
result.insert("album".into(), info.into());
|
||||
}
|
||||
if let Some(value) = value.get("artist").filter(|value| !value.is_null()) {
|
||||
let mut info = strings(
|
||||
value,
|
||||
&[
|
||||
"id",
|
||||
"name",
|
||||
"image_url",
|
||||
"header_image",
|
||||
"header_video",
|
||||
"provider_id",
|
||||
],
|
||||
);
|
||||
info.insert(
|
||||
"listeners".into(),
|
||||
json!(value["listeners"].as_i64().unwrap_or_default()),
|
||||
);
|
||||
for key in ["albums", "releases"] {
|
||||
let values = array(value, key);
|
||||
if !values.is_empty() {
|
||||
info.insert(
|
||||
key.into(),
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let mut value = album(value, false);
|
||||
if value["album_type"] == "" {
|
||||
value["album_type"] = "album".into();
|
||||
}
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let values = array(value, "top_tracks");
|
||||
if !values.is_empty() {
|
||||
info.insert("top_tracks".into(), url_tracks(values));
|
||||
}
|
||||
result.insert("artist".into(), info.into());
|
||||
}
|
||||
self.check()?;
|
||||
serde_json::to_string(&result).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn custom_search_json(
|
||||
&self,
|
||||
id: &str,
|
||||
query: &str,
|
||||
options_json: &str,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let options = serde_json::from_str::<Map<String, Value>>(options_json).unwrap_or_default();
|
||||
let raw = self
|
||||
.manager
|
||||
.provider_call(
|
||||
id,
|
||||
"customSearch",
|
||||
&json!([query, options]).to_string(),
|
||||
lease.clone(),
|
||||
30_000,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let values: Vec<Value> = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
|
||||
let normalized = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
self.check()?;
|
||||
if let Some(lease) = &lease {
|
||||
lease.check_active().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(track(value, "", 0))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
serde_json::to_string(&normalized).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_extension_home_feed_json(
|
||||
&self,
|
||||
id: &str,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
self.manager
|
||||
.provider_call(id, "getHomeFeed", "[]", lease, 60_000)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn get_provider_metadata_json(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
kind: &str,
|
||||
resource_id: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(30, check, |check| {
|
||||
let id = provider_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(ResolverError::Failed("empty provider ID".into()));
|
||||
}
|
||||
let available = self.manager.require_metadata_provider(id);
|
||||
// Only built-in dispatch folds case; installed IDs retain their case.
|
||||
if lowercase(id) == "deezer" && available.is_err() {
|
||||
return self.deezer_metadata(kind, resource_id, check);
|
||||
}
|
||||
available.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
let method = match kind {
|
||||
"track" => "getTrack",
|
||||
"album" => "getAlbum",
|
||||
"playlist" => "getPlaylist",
|
||||
"artist" => "getArtist",
|
||||
_ => {
|
||||
return Err(ResolverError::Failed(format!(
|
||||
"unsupported provider resource type: {kind}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let arguments = json!([resource_id]).to_string();
|
||||
let result = self.provider_metadata_call(id, method, &arguments, check)?;
|
||||
let result = response(kind, &result, check)?;
|
||||
serde_json::to_string(&result).map_err(|error| ResolverError::Failed(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn provider_metadata_call(
|
||||
&self,
|
||||
id: &str,
|
||||
method: &str,
|
||||
arguments: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<Value, ResolverError> {
|
||||
self.metadata_provider_work(check, |lease| {
|
||||
self.manager
|
||||
.provider_call(id, method, arguments, Some(lease), 30_000)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn metadata_provider_work(
|
||||
&self,
|
||||
check: &Check<'_>,
|
||||
work: impl FnOnce(Arc<RequestLease>) -> Result<String, ManagerError> + Send,
|
||||
) -> Result<Value, ResolverError> {
|
||||
let cancellation = CancellationRegistry::new(CancellationDomain::ExtensionRequest);
|
||||
let lease = Arc::new(
|
||||
cancellation
|
||||
.acquire("")
|
||||
.expect("new metadata cancellation lease"),
|
||||
);
|
||||
std::thread::scope(|scope| {
|
||||
let (done, completion) = mpsc::channel::<()>();
|
||||
let worker_lease = &lease;
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("provider-metadata".into())
|
||||
.spawn_scoped(scope, move || {
|
||||
let _done = done;
|
||||
work(worker_lease.clone())
|
||||
})
|
||||
.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
let mut cancelled = None;
|
||||
loop {
|
||||
if let Err(message) = check() {
|
||||
cancelled = Some(message);
|
||||
lease.release();
|
||||
break;
|
||||
}
|
||||
if !matches!(
|
||||
completion.recv_timeout(Duration::from_millis(5)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let result = worker
|
||||
.join()
|
||||
.map_err(|_| ResolverError::Failed("metadata provider panicked".into()))?;
|
||||
lease.release();
|
||||
if let Some(message) = cancelled {
|
||||
return Err(ResolverError::Cancelled(message));
|
||||
}
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
let result = result.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
serde_json::from_str(&result).map_err(|error| ResolverError::Failed(error.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Inputs have already passed the installed provider's typed decoder. Fill the
|
||||
// fields that Go's internal omitempty serializer drops before the public map.
|
||||
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
|
||||
value[key].as_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn strings(value: &Value, fields: &[&str]) -> Map<String, Value> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|key| ((*key).into(), json!(text(value, key))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn array<'a>(value: &'a Value, key: &str) -> &'a [Value] {
|
||||
value[key].as_array().map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn url_tracks(values: &[Value]) -> Value {
|
||||
values.iter().map(|value| track(value, "", 0)).collect()
|
||||
}
|
||||
|
||||
fn track(value: &Value, cover: &str, index: usize) -> Value {
|
||||
let mut result = strings(
|
||||
value,
|
||||
&[
|
||||
"id",
|
||||
"name",
|
||||
"artists",
|
||||
"album_name",
|
||||
"album_artist",
|
||||
"album_id",
|
||||
"album_url",
|
||||
"artist_id",
|
||||
"artist_url",
|
||||
"external_urls",
|
||||
"preview_url",
|
||||
"release_date",
|
||||
"isrc",
|
||||
"provider_id",
|
||||
"item_type",
|
||||
"album_type",
|
||||
"spotify_id",
|
||||
"genre",
|
||||
"label",
|
||||
"copyright",
|
||||
"composer",
|
||||
"comment",
|
||||
"audio_quality",
|
||||
"audio_modes",
|
||||
"upc",
|
||||
],
|
||||
);
|
||||
let cover = [text(value, "cover_url"), text(value, "images"), cover]
|
||||
.into_iter()
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default();
|
||||
result.insert("images".into(), json!(cover));
|
||||
result.insert("cover_url".into(), json!(cover));
|
||||
for key in [
|
||||
"duration_ms",
|
||||
"track_number",
|
||||
"total_tracks",
|
||||
"disc_number",
|
||||
"total_discs",
|
||||
] {
|
||||
result.insert(key.into(), json!(value[key].as_i64().unwrap_or_default()));
|
||||
}
|
||||
if result["track_number"] == 0 && index > 0 {
|
||||
result.insert("track_number".into(), json!(index));
|
||||
}
|
||||
result.insert(
|
||||
"explicit".into(),
|
||||
json!(value["explicit"].as_bool().unwrap_or(false)),
|
||||
);
|
||||
result.insert("external_links".into(), value["external_links"].clone());
|
||||
result.into()
|
||||
}
|
||||
|
||||
fn tracks(values: &[Value], cover: &str, check: &Check<'_>) -> Result<Value, ResolverError> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
Ok(track(value, cover, index + 1))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Value::Array)
|
||||
}
|
||||
|
||||
fn album(value: &Value, full: bool) -> Value {
|
||||
let mut result = strings(
|
||||
value,
|
||||
&[
|
||||
"id",
|
||||
"name",
|
||||
"artists",
|
||||
"cover_url",
|
||||
"release_date",
|
||||
"album_type",
|
||||
"provider_id",
|
||||
],
|
||||
);
|
||||
result.insert("images".into(), json!(text(value, "cover_url")));
|
||||
result.insert(
|
||||
"total_tracks".into(),
|
||||
json!(value["total_tracks"].as_i64().unwrap_or_default()),
|
||||
);
|
||||
if full {
|
||||
result.extend(strings(
|
||||
value,
|
||||
&["artist_id", "header_image", "header_video"],
|
||||
));
|
||||
result.insert("audio_traits".into(), json!(array(value, "audio_traits")));
|
||||
}
|
||||
result.into()
|
||||
}
|
||||
|
||||
fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result<Value, ResolverError> {
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
let cover = text(value, "cover_url");
|
||||
Ok(match kind {
|
||||
"track" => json!({"track":track(value, "", 0)}),
|
||||
"album" => {
|
||||
json!({"album_info":album(value, true),"track_list":tracks(array(value,"tracks"),cover,check)?})
|
||||
}
|
||||
"playlist" => {
|
||||
let mut info = strings(
|
||||
value,
|
||||
&[
|
||||
"id",
|
||||
"name",
|
||||
"cover_url",
|
||||
"header_image",
|
||||
"header_video",
|
||||
"provider_id",
|
||||
],
|
||||
);
|
||||
info.insert("images".into(), json!(cover));
|
||||
info.insert(
|
||||
"owner".into(),
|
||||
json!({"name":text(value,"artists"),"images":cover}),
|
||||
);
|
||||
json!({"playlist_info":info,"track_list":tracks(array(value,"tracks"),cover,check)?})
|
||||
}
|
||||
"artist" => {
|
||||
let cover = text(value, "image_url");
|
||||
let image = [text(value, "header_image"), cover]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default();
|
||||
let mut info = strings(
|
||||
value,
|
||||
&["id", "name", "header_image", "header_video", "provider_id"],
|
||||
);
|
||||
info.insert("images".into(), json!(image));
|
||||
info.insert("cover_url".into(), json!(cover));
|
||||
if value["listeners"].as_i64().unwrap_or_default() > 0 {
|
||||
info.insert("listeners".into(), value["listeners"].clone());
|
||||
}
|
||||
let mut result = json!({"artist_info":info});
|
||||
for key in ["albums", "releases"] {
|
||||
let values = array(value, key);
|
||||
if key == "albums" || !values.is_empty() {
|
||||
result[key] = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
Ok(album(value, false))
|
||||
})
|
||||
.collect::<Result<Vec<_>, ResolverError>>()?
|
||||
.into();
|
||||
}
|
||||
}
|
||||
let top = array(value, "top_tracks");
|
||||
if !top.is_empty() {
|
||||
result["top_tracks"] = tracks(top, cover, check)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => unreachable!("validated provider resource type"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::RuntimeLimits;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
fn backend(root: &Path) -> Backend {
|
||||
Backend::new(
|
||||
&root.join("sources"),
|
||||
&root.join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_provider_work_returns_immediate_json() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let backend = backend(root.path());
|
||||
let result = backend
|
||||
.metadata_provider_work(&|| Ok(()), |_| Ok(r#"{"immediate":true}"#.into()))
|
||||
.unwrap();
|
||||
assert_eq!(result["immediate"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_provider_work_waits_for_delayed_worker_completion() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let backend = Arc::new(backend(root.path()));
|
||||
let started = Arc::new(Barrier::new(2));
|
||||
let release = Arc::new(Barrier::new(2));
|
||||
let worker_backend = Arc::clone(&backend);
|
||||
let worker_started = Arc::clone(&started);
|
||||
let worker_release = Arc::clone(&release);
|
||||
let worker = thread::spawn(move || {
|
||||
worker_backend.metadata_provider_work(&|| Ok(()), move |_| {
|
||||
worker_started.wait();
|
||||
worker_release.wait();
|
||||
Ok(r#"{"delayed":true}"#.into())
|
||||
})
|
||||
});
|
||||
started.wait();
|
||||
release.wait();
|
||||
assert_eq!(worker.join().unwrap().unwrap()["delayed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_provider_work_reports_worker_panic() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let backend = backend(root.path());
|
||||
let result = backend
|
||||
.metadata_provider_work(&|| Ok(()), |_| -> Result<String, ManagerError> {
|
||||
panic!("metadata worker failed")
|
||||
});
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ResolverError::Failed("metadata provider panicked".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_provider_work_releases_lease_and_joins_cancelled_worker() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let backend = backend(root.path());
|
||||
let started = Arc::new(AtomicBool::new(false));
|
||||
let joined = Arc::new(AtomicBool::new(false));
|
||||
let worker_started = Arc::clone(&started);
|
||||
let worker_joined = Arc::clone(&joined);
|
||||
let result = backend.metadata_provider_work(
|
||||
&|| {
|
||||
if started.load(Ordering::Acquire) {
|
||||
Err("cancelled by test".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move |lease| {
|
||||
worker_started.store(true, Ordering::Release);
|
||||
assert!(lease.wait_cancelled(60_000).is_err());
|
||||
worker_joined.store(true, Ordering::Release);
|
||||
Ok("{}".into())
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ResolverError::Cancelled("cancelled by test".into()))
|
||||
);
|
||||
assert!(joined.load(Ordering::Acquire));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use super::{Backend, LyricsRequest};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use serde_json::json;
|
||||
use spotiflac_core::lyrics::{file, lrc};
|
||||
use spotiflac_core::metadata::TrackMetadata;
|
||||
use spotiflac_core::metadata::reenrich::{self, Request};
|
||||
use spotiflac_core::tags::{embed_flac_metadata, extract_cover};
|
||||
use spotiflac_providers::deezer::MetadataLookup;
|
||||
use spotiflac_providers::resolver::{Check, availability};
|
||||
use std::io::Write;
|
||||
use std::sync::Mutex;
|
||||
|
||||
impl Backend {
|
||||
/// Resolve proposed tags without reading audio, downloading covers or writing files.
|
||||
pub fn preview_reenrich_file(
|
||||
&self,
|
||||
request_json: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || {
|
||||
self.check()?;
|
||||
check()
|
||||
};
|
||||
check()?;
|
||||
let request = self.resolve_reenrich_request(request_json, &check)?;
|
||||
serde_json::to_string(&json!({"method":"preview","success":true,
|
||||
"enriched_metadata":request.result_metadata()}))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Execute FLAC enrichment or return the existing native FFmpeg plan.
|
||||
/// Returned cover files belong to the caller, which removes them after use.
|
||||
pub fn reenrich_file(&self, request_json: &str, check: &Check<'_>) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = Mutex::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.lock().expect("reenrich cancellation lock").as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.lock().expect("reenrich cancellation lock") = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
let request = self.resolve_reenrich_request(request_json, &check)?;
|
||||
let enriched = request.result_metadata();
|
||||
if request.preview_only {
|
||||
return Ok(json!({"method":"preview","success":true,
|
||||
"enriched_metadata":enriched})
|
||||
.to_string());
|
||||
}
|
||||
let files = self.environment().native_files()?;
|
||||
files.resolve_legacy(&request.file_path)?.native_display()?;
|
||||
let is_flac = request.file_path.to_ascii_lowercase().ends_with(".flac");
|
||||
let wants_cover = !request.cover_url.is_empty() && request.selected("cover", "cover");
|
||||
let fetch_cover = || {
|
||||
if wants_cover {
|
||||
self.cover
|
||||
.download(
|
||||
&request.cover_url,
|
||||
request.cover_max_dimension as i64,
|
||||
&check,
|
||||
)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let fetch_lyrics = || -> Result<String, String> {
|
||||
// Fetches and existing lyrics are best-effort; cancellation is not.
|
||||
check()?;
|
||||
let mut lyrics = String::new();
|
||||
if request.selected("lyrics", "lyrics") {
|
||||
if let Ok(existing) = file::extract(
|
||||
&request.file_path,
|
||||
&|path| {
|
||||
files
|
||||
.resolve_legacy(path)?
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
&check,
|
||||
) {
|
||||
lyrics = existing;
|
||||
}
|
||||
check()?;
|
||||
if request.embed_lyrics {
|
||||
let query = LyricsRequest {
|
||||
spotify_id: request.spotify_id.clone(),
|
||||
track: request.track_name.clone(),
|
||||
artist: request.artist_name.clone(),
|
||||
duration_ms: request.duration_ms,
|
||||
..LyricsRequest::default()
|
||||
};
|
||||
if let Ok(response) = self.fetch_lyrics(&query, &check)
|
||||
&& !response.instrumental
|
||||
{
|
||||
lyrics = lrc::with_metadata(&response, &query.track, &query.artist);
|
||||
}
|
||||
check()?;
|
||||
}
|
||||
}
|
||||
Ok(lyrics)
|
||||
};
|
||||
let (cover, lyrics) = std::thread::scope(|scope| {
|
||||
let worker = (wants_cover && request.selected("lyrics", "lyrics")).then(|| {
|
||||
std::thread::Builder::new()
|
||||
.name("reenrich-cover".into())
|
||||
.spawn_scoped(scope, fetch_cover)
|
||||
});
|
||||
let lyrics = fetch_lyrics();
|
||||
let cover = match worker {
|
||||
Some(Ok(worker)) => worker
|
||||
.join()
|
||||
.map_err(|_| "cover worker panicked".to_owned())?,
|
||||
_ => fetch_cover(),
|
||||
};
|
||||
check()?;
|
||||
Ok::<_, String>((cover, lyrics?))
|
||||
})?;
|
||||
let metadata = request.write_metadata(&lyrics);
|
||||
let external = request.write_external_lrc(&lyrics);
|
||||
if is_flac {
|
||||
let cover = cover.as_deref().filter(|data| !data.is_empty());
|
||||
let prefix = if cover.is_some() {
|
||||
"failed to embed metadata with cover"
|
||||
} else {
|
||||
"failed to embed metadata"
|
||||
};
|
||||
self.rewrite_tag_file(&request.file_path, &check, |source, output, _| {
|
||||
embed_flac_metadata(
|
||||
source,
|
||||
output,
|
||||
&metadata,
|
||||
&request.artist_tag_mode,
|
||||
cover,
|
||||
&check,
|
||||
)
|
||||
.map_err(|error| format!("{prefix}: {error}"))?;
|
||||
// Verify the staged result before replacing the user's audio.
|
||||
if cover.is_some() {
|
||||
let picture = extract_cover(output, "flac", &check).map_err(|error| {
|
||||
format!("metadata embedded but cover verification failed: {error}")
|
||||
})?;
|
||||
if picture.data.is_empty() {
|
||||
return Err(
|
||||
"metadata embedded but cover verification failed: empty embedded cover"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| {
|
||||
if error.starts_with("failed to embed metadata")
|
||||
|| error.starts_with("metadata embedded")
|
||||
{
|
||||
error
|
||||
} else {
|
||||
format!("{prefix}: {error}")
|
||||
}
|
||||
})?;
|
||||
return Ok(
|
||||
json!({"method":"native","success":true,"enriched_metadata":enriched,
|
||||
"lyrics":lyrics,"write_external_lrc":external})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let temporary = cover.as_deref().and_then(|data| {
|
||||
let write = || -> Result<tempfile::NamedTempFile, String> {
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("reenrich_cover_")
|
||||
.suffix(".jpg")
|
||||
.tempfile_in(self.environment().data_directory())
|
||||
.map_err(|error| error.to_string())?;
|
||||
for chunk in data.chunks(65536) {
|
||||
check()?;
|
||||
file.write_all(chunk).map_err(|error| error.to_string())?;
|
||||
}
|
||||
file.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(file)
|
||||
};
|
||||
write().ok()
|
||||
});
|
||||
check()?;
|
||||
let cover_path = if let Some(file) = &temporary {
|
||||
files
|
||||
.resolve_legacy(&file.path().to_string_lossy())?
|
||||
.native_display()?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics,
|
||||
"enriched_metadata":enriched,"metadata":metadata,"write_external_lrc":external})
|
||||
.to_string();
|
||||
check()?;
|
||||
if let Some(file) = temporary {
|
||||
file.keep().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_reenrich_request(
|
||||
&self,
|
||||
request_json: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<Request, String> {
|
||||
let mut request = Request::parse(request_json)
|
||||
.map_err(|error| format!("failed to parse request: {error}"))?;
|
||||
if request.file_path.is_empty() {
|
||||
return Err("file_path is required".into());
|
||||
}
|
||||
if request.search_online {
|
||||
let mut found = false;
|
||||
let catalog_track = self.reenrich_identifiers(&request, &check);
|
||||
if let Some(track) = &catalog_track {
|
||||
request.apply(&reenrich::from_catalog(track));
|
||||
found = true;
|
||||
}
|
||||
check()?;
|
||||
let query = request.query();
|
||||
if !query.is_empty() {
|
||||
let tracks = self.metadata_provider_work(&check, |lease| {
|
||||
self.manager.search_metadata_providers_with_lease(
|
||||
&query,
|
||||
5,
|
||||
true,
|
||||
"",
|
||||
30_000,
|
||||
Some(lease),
|
||||
)
|
||||
});
|
||||
check()?;
|
||||
if let Ok(tracks) = tracks
|
||||
&& let Some(tracks) = tracks.as_array()
|
||||
&& let Some(track) = request.select(tracks)
|
||||
{
|
||||
request.apply(track);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if request.selected("basic_tags", "album_artist")
|
||||
&& request.album_artist.is_empty()
|
||||
&& !request.isrc.is_empty()
|
||||
{
|
||||
let artist = self.fetch_music_brainz_album_artist_by_isrc(
|
||||
&request.isrc,
|
||||
&request.album_name,
|
||||
&check,
|
||||
);
|
||||
check()?;
|
||||
if let Ok(artist) = artist
|
||||
&& !artist.trim().is_empty()
|
||||
{
|
||||
request.album_artist = artist.trim().into();
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if found
|
||||
&& !request.isrc.is_empty()
|
||||
&& request.any_selected("extra", &["genre", "label", "copyright"])
|
||||
&& (request.genre.is_empty()
|
||||
|| request.label.is_empty()
|
||||
|| request.copyright.is_empty())
|
||||
{
|
||||
let isrc = request.isrc.trim();
|
||||
if !isrc.is_empty() {
|
||||
let extended = self.metadata_operation(10, &check, |check| {
|
||||
if let Some(track) = catalog_track
|
||||
.as_ref()
|
||||
.filter(|track| track.isrc.trim().eq_ignore_ascii_case(isrc))
|
||||
{
|
||||
return self.deezer.get_extended_metadata_for_track(track, check);
|
||||
}
|
||||
self.deezer.get_extended_metadata_by_isrc(isrc, check)
|
||||
});
|
||||
check()?;
|
||||
if let Ok(extended) = extended {
|
||||
for (target, value) in [
|
||||
(&mut request.genre, &extended.genre),
|
||||
(&mut request.label, &extended.label),
|
||||
(&mut request.copyright, &extended.copyright),
|
||||
] {
|
||||
if target.is_empty() && !value.is_empty() {
|
||||
*target = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.genre.is_empty() {
|
||||
let genre = self.fetch_music_brainz_genre_by_isrc(isrc, &check);
|
||||
check()?;
|
||||
if let Ok(genre) = genre
|
||||
&& !genre.is_empty()
|
||||
{
|
||||
request.genre = genre;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn reenrich_identifiers(&self, request: &Request, check: &Check<'_>) -> Option<TrackMetadata> {
|
||||
let isrc = request.isrc.trim();
|
||||
if !isrc.is_empty() {
|
||||
let track =
|
||||
self.metadata_operation(10, check, |check| self.deezer.search_by_isrc(isrc, check));
|
||||
if check().is_err() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(track) = track
|
||||
&& request.verified(&reenrich::from_catalog(&track))
|
||||
{
|
||||
return Some(track);
|
||||
}
|
||||
}
|
||||
let source = request.spotify_id.trim();
|
||||
if source.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut deezer_id = source
|
||||
.strip_prefix("deezer:")
|
||||
.unwrap_or(source)
|
||||
.trim()
|
||||
.to_owned();
|
||||
if deezer_id == source {
|
||||
deezer_id = availability::deezer_id_from_url(source);
|
||||
}
|
||||
if deezer_id.is_empty() {
|
||||
let mut spotify_id = availability::spotify_id_from_url(source);
|
||||
if spotify_id.is_empty() && source.len() == 22 && !source.contains([':', '/']) {
|
||||
spotify_id = source.into();
|
||||
}
|
||||
if !spotify_id.is_empty() {
|
||||
if let Ok(id) = self.get_deezer_id_from_spotify(&spotify_id, check) {
|
||||
deezer_id = id.trim().into();
|
||||
}
|
||||
if check().is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if deezer_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let track = self
|
||||
.metadata_operation(15, check, |check| self.deezer.get_track(&deezer_id, check))
|
||||
.ok()?;
|
||||
request
|
||||
.verified(&reenrich::from_catalog(&track))
|
||||
.then_some(track)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use super::Backend;
|
||||
use serde_json::Value;
|
||||
use spotiflac_core::metadata::share::{self, Provider, Request, ShareResult};
|
||||
use spotiflac_providers::resolver::{Check, ResolverError};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cache {
|
||||
values: BTreeMap<(u64, String), String>,
|
||||
order: VecDeque<(u64, String)>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn clear(&mut self) {
|
||||
self.values.clear();
|
||||
self.order.clear();
|
||||
}
|
||||
|
||||
fn put(&mut self, key: (u64, String), value: String) {
|
||||
if !self.values.contains_key(&key) {
|
||||
self.order.push_back(key.clone());
|
||||
}
|
||||
self.values.insert(key, value);
|
||||
while self.order.len() > 128 {
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
self.values.remove(&oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn find_collection_across_extensions_json(
|
||||
&self,
|
||||
request_json: &str,
|
||||
check: &Check<'_>,
|
||||
) -> Result<String, String> {
|
||||
self.metadata_operation(60, check, |check| {
|
||||
let request = Request::parse(request_json)
|
||||
.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
if request.name.is_empty() {
|
||||
return Ok("[]".into());
|
||||
}
|
||||
let revision = self.manager.metadata_revision();
|
||||
let providers = self
|
||||
.manager
|
||||
.share_providers(&request.source_extension_id)
|
||||
.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
let key = (revision, request.cache_key(&providers));
|
||||
if let Some(cached) = self
|
||||
.share_cache
|
||||
.lock()
|
||||
.expect("share cache lock")
|
||||
.values
|
||||
.get(&key)
|
||||
{
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let query = request.query();
|
||||
// Go searches all providers concurrently; retain snapshot order on join.
|
||||
let results = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
let request = &request;
|
||||
let query = &query;
|
||||
scope
|
||||
.spawn(move || self.share_for_provider(provider, request, query, check))
|
||||
})
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.map(|worker| {
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| ResolverError::Failed("collection search panicked".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})?;
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
let response = serde_json::to_string(&results)
|
||||
.map_err(|error| ResolverError::Failed(error.to_string()))?;
|
||||
if revision == self.manager.metadata_revision()
|
||||
&& results.iter().all(ShareResult::cacheable)
|
||||
{
|
||||
self.share_cache
|
||||
.lock()
|
||||
.expect("share cache lock")
|
||||
.put(key, response.clone());
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
|
||||
fn share_for_provider(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
request: &Request,
|
||||
query: &str,
|
||||
check: &Check<'_>,
|
||||
) -> ShareResult {
|
||||
let fetch = || -> Result<Vec<Value>, ResolverError> {
|
||||
let filter = match request.kind.as_str() {
|
||||
"album" => "albums",
|
||||
"artist" => "artists",
|
||||
_ => "",
|
||||
};
|
||||
if !filter.is_empty() {
|
||||
let arguments =
|
||||
serde_json::json!([query, {"filter":filter,"limit":10}]).to_string();
|
||||
let custom =
|
||||
self.provider_metadata_call(&provider.id, "customSearch", &arguments, check);
|
||||
check().map_err(ResolverError::Cancelled)?;
|
||||
if let Ok(Value::Array(tracks)) = custom
|
||||
&& !tracks.is_empty()
|
||||
{
|
||||
return Ok(tracks);
|
||||
}
|
||||
}
|
||||
let arguments = serde_json::json!([query, 10]).to_string();
|
||||
let result =
|
||||
self.provider_metadata_call(&provider.id, "searchTracks", &arguments, check)?;
|
||||
Ok(result["tracks"].as_array().cloned().unwrap_or_default())
|
||||
};
|
||||
match fetch() {
|
||||
Ok(tracks) => share::select(provider, request, &tracks),
|
||||
Err(error) => {
|
||||
let mut result = ShareResult::new(provider);
|
||||
result.error = error.to_string();
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Cache;
|
||||
|
||||
#[test]
|
||||
fn collection_share_cache_is_bounded_fifo_and_revisions_are_distinct() {
|
||||
let mut cache = Cache::default();
|
||||
for index in 0..128 {
|
||||
cache.put((0, index.to_string()), "original".into());
|
||||
}
|
||||
assert!(cache.values.contains_key(&(0, "0".into())));
|
||||
cache.put((0, "0".into()), "updated".into());
|
||||
cache.put((1, "0".into()), "new revision".into());
|
||||
assert_eq!(cache.values.len(), 128);
|
||||
assert!(!cache.values.contains_key(&(0, "0".into())));
|
||||
assert!(cache.values.contains_key(&(0, "1".into())));
|
||||
assert_eq!(cache.values[&(1, "0".into())], "new revision");
|
||||
cache.clear();
|
||||
assert!(cache.values.is_empty() && cache.order.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use super::Backend;
|
||||
use crate::files::ExtensionFiles;
|
||||
use cap_std::fs::{File, OpenOptions};
|
||||
use serde_json::{Value, json};
|
||||
use spotiflac_core::tags::{
|
||||
embed_flac_metadata, rewrite_ac4_config, rewrite_ac4_metadata, rewrite_audio_tags,
|
||||
rewrite_m4a_freeform,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
|
||||
impl Backend {
|
||||
pub fn rewrite_split_artist_tags(
|
||||
&self,
|
||||
path: &str,
|
||||
artist: &str,
|
||||
album_artist: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<String, String> {
|
||||
let fields = BTreeMap::from([
|
||||
("ARTIST".into(), artist.into()),
|
||||
("ALBUMARTIST".into(), album_artist.into()),
|
||||
]);
|
||||
self.embed_flac_fields_response(
|
||||
path,
|
||||
&fields,
|
||||
"split_vorbis",
|
||||
(
|
||||
"Failed to rewrite artist tags",
|
||||
"Split artist tags written successfully",
|
||||
),
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn embed_flac_fields_response(
|
||||
&self,
|
||||
path: &str,
|
||||
fields: &BTreeMap<String, String>,
|
||||
artist_mode: &str,
|
||||
messages: (&str, &str),
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<String, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
self.environment()
|
||||
.native_files()?
|
||||
.resolve_legacy(path)?
|
||||
.native_display()?;
|
||||
if let Err(error) = self.rewrite_tag_file(path, &check, |source, output, _| {
|
||||
embed_flac_metadata(source, output, fields, artist_mode, None, &check)?;
|
||||
Ok(true)
|
||||
}) {
|
||||
check()?;
|
||||
return Ok(
|
||||
crate::download::native_error_response(&format!("{}: {error}", messages.0))
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(json!({"success":true,"message":messages.1}).to_string())
|
||||
}
|
||||
|
||||
/// Preserve the application facade, including native-editor fallback to
|
||||
/// FFmpeg. Permission and lifecycle failures must never become success.
|
||||
pub fn edit_file_metadata(
|
||||
&self,
|
||||
path: &str,
|
||||
metadata_json: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let failure = RefCell::new(None::<String>);
|
||||
let check = || {
|
||||
if let Some(error) = failure.borrow().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
self.check().and_then(|()| check()).inspect_err(|error| {
|
||||
*failure.borrow_mut() = Some(error.clone());
|
||||
})
|
||||
};
|
||||
check()?;
|
||||
let decoded: Option<BTreeMap<String, Option<String>>> = serde_json::from_str(metadata_json)
|
||||
.map_err(|error| format!("invalid metadata JSON: {error}"))?;
|
||||
let null_fields = decoded.is_none();
|
||||
let fields: BTreeMap<String, String> = decoded
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, value.unwrap_or_default()))
|
||||
.collect();
|
||||
let files = self.manager.environment().native_files()?;
|
||||
let target = files.resolve_legacy(path)?;
|
||||
target.native_display()?;
|
||||
if let Some(cover) = fields.get("cover_path").filter(|p| !p.trim().is_empty()) {
|
||||
files.resolve_legacy(cover.trim())?.native_display()?;
|
||||
}
|
||||
let lower = path.to_lowercase();
|
||||
let suffix = |extensions: &[&str]| extensions.iter().any(|ext| lower.ends_with(ext));
|
||||
let m4a = suffix(&[".m4a", ".mp4", ".m4b"]);
|
||||
let mp4 = target
|
||||
.open(OpenOptions::new().read(true))
|
||||
.ok()
|
||||
.is_some_and(|mut file| {
|
||||
let mut header = [0; 12];
|
||||
file.read(&mut header)
|
||||
.is_ok_and(|count| count >= 8 && &header[4..8] == b"ftyp")
|
||||
});
|
||||
check()?;
|
||||
let mut replay_gain = false;
|
||||
let only_replay_gain = fields
|
||||
.iter()
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.all(|(key, _)| {
|
||||
let allowed = matches!(
|
||||
key.trim().to_lowercase().as_str(),
|
||||
"replaygain_track_gain"
|
||||
| "replaygain_track_peak"
|
||||
| "replaygain_album_gain"
|
||||
| "replaygain_album_peak"
|
||||
);
|
||||
replay_gain |= allowed;
|
||||
allowed
|
||||
});
|
||||
let success = |method: &str| json!({"success":true,"method":method});
|
||||
if only_replay_gain && replay_gain && (m4a || mp4) {
|
||||
self.edit_m4a_freeform(path, &fields, true, &check)
|
||||
.map_err(|error| format!("failed to write M4A metadata: {error}"))?;
|
||||
return Ok(success("native_m4a_replaygain"));
|
||||
}
|
||||
let required = if suffix(&[".flac"]) {
|
||||
if mp4 {
|
||||
return Err("failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a".into());
|
||||
}
|
||||
Some(("flac", "native", "failed to write FLAC metadata"))
|
||||
} else if suffix(&[".wav"]) {
|
||||
Some(("wav", "native_wav", "failed to write WAV metadata"))
|
||||
} else if suffix(&[".aiff", ".aif", ".aifc"]) {
|
||||
Some(("aiff", "native_aiff", "failed to write AIFF metadata"))
|
||||
} else if suffix(&[".ape", ".wv", ".mpc"]) {
|
||||
Some(("ape", "native_ape", "failed to write APE tags"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((format, method, prefix)) = required {
|
||||
self.edit_audio_tags(path, format, &fields, &check)
|
||||
.map_err(|error| format!("{prefix}: {error}"))?;
|
||||
return Ok(success(method));
|
||||
}
|
||||
for (applicable, format, method) in [
|
||||
(suffix(&[".mp3"]), "mp3", "native_mp3"),
|
||||
(suffix(&[".ogg", ".opus"]), "ogg", "native_ogg"),
|
||||
(m4a || mp4, "m4a", "native_m4a"),
|
||||
] {
|
||||
if applicable {
|
||||
if self.edit_audio_tags(path, format, &fields, &check).is_ok() {
|
||||
return Ok(success(method));
|
||||
}
|
||||
// Recheck capabilities after a failed attempt too: a revoked
|
||||
// grant or replaced symlink must not be handed to FFmpeg.
|
||||
let files = self.manager.environment().native_files()?;
|
||||
files.resolve_legacy(path)?.native_display()?;
|
||||
if let Some(cover) = fields.get("cover_path").filter(|p| !p.trim().is_empty()) {
|
||||
files.resolve_legacy(cover.trim())?.native_display()?;
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
}
|
||||
Ok(
|
||||
json!({"success":true,"method":"ffmpeg","fields":if null_fields { Value::Null } else { json!(fields) }}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Edit supported tag containers through the same file grants and output
|
||||
/// locks as downloads/lyrics. Platform descriptors are staged by the native
|
||||
/// adapter before calling this path-based, atomic publication operation.
|
||||
pub fn edit_audio_tags(
|
||||
&self,
|
||||
path: &str,
|
||||
format: &str,
|
||||
fields: &BTreeMap<String, String>,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
if !matches!(
|
||||
format,
|
||||
"flac"
|
||||
| "mp3"
|
||||
| "m4a"
|
||||
| "mp4"
|
||||
| "m4b"
|
||||
| "ogg"
|
||||
| "opus"
|
||||
| "wav"
|
||||
| "aiff"
|
||||
| "aif"
|
||||
| "aifc"
|
||||
| "ape"
|
||||
| "wv"
|
||||
| "mpc"
|
||||
) {
|
||||
return Err(format!("unsupported tag writer: {format}"));
|
||||
}
|
||||
self.rewrite_tag_file(path, &check, |source, output, files| {
|
||||
let cover = read_cover(
|
||||
files,
|
||||
fields.get("cover_path").map(|p| p.trim()),
|
||||
matches!(format, "wav" | "aiff" | "aif" | "aifc"),
|
||||
&check,
|
||||
)?;
|
||||
rewrite_audio_tags(source, output, format, fields, cover.as_deref(), &check)?;
|
||||
Ok(true)
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn edit_m4a_freeform(
|
||||
&self,
|
||||
path: &str,
|
||||
fields: &BTreeMap<String, String>,
|
||||
replay_gain_only: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<bool, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
let applicable = if replay_gain_only {
|
||||
[
|
||||
"replaygain_track_gain",
|
||||
"replaygain_track_peak",
|
||||
"replaygain_album_gain",
|
||||
"replaygain_album_peak",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| fields.get(*key).is_some_and(|v| !v.trim().is_empty()))
|
||||
} else {
|
||||
fields.contains_key("isrc") || fields.contains_key("label")
|
||||
};
|
||||
if !applicable {
|
||||
return Ok(false);
|
||||
}
|
||||
self.rewrite_tag_file(path, &check, |source, output, _| {
|
||||
rewrite_m4a_freeform(source, output, fields, replay_gain_only, &check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_ac4_config(
|
||||
&self,
|
||||
path: &str,
|
||||
reference: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
self.rewrite_tag_file(path, &check, |source, output, files| {
|
||||
rewrite_ac4_config(
|
||||
source,
|
||||
output,
|
||||
|| {
|
||||
files
|
||||
.resolve_legacy(reference)?
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
&check,
|
||||
)
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn write_ac4_metadata(
|
||||
&self,
|
||||
path: &str,
|
||||
metadata_json: &str,
|
||||
cover_path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<bool, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
self.rewrite_tag_file(path, &check, |source, output, files| {
|
||||
rewrite_ac4_metadata(
|
||||
source,
|
||||
output,
|
||||
metadata_json,
|
||||
|| read_cover(files, Some(cover_path), false, &check),
|
||||
&check,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Callers hold the root operation guard and supply the combined root/lease check.
|
||||
pub(super) fn rewrite_tag_file(
|
||||
&self,
|
||||
path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
edit: impl FnOnce(&mut File, &mut File, &ExtensionFiles) -> Result<bool, String>,
|
||||
) -> Result<bool, String> {
|
||||
let files = self.manager.environment().native_files()?;
|
||||
let target = files.resolve_legacy(path)?;
|
||||
let _lock = files.lock(&target, check)?;
|
||||
target.native_display()?;
|
||||
target.require_parent()?;
|
||||
let mut source = target
|
||||
.open(OpenOptions::new().read(true).write(true))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let permissions = source.metadata().map_err(|e| e.to_string())?.permissions();
|
||||
let mut stage = target.stage_existing_parent().map_err(|e| e.to_string())?;
|
||||
stage
|
||||
.file
|
||||
.set_permissions(permissions)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let changed = edit(&mut source, &mut stage.file, &files)?;
|
||||
check()?;
|
||||
drop(source);
|
||||
if changed {
|
||||
stage.publish(check)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cover(
|
||||
files: &ExtensionFiles,
|
||||
path: Option<&str>,
|
||||
required: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some(path) = path.filter(|path| !path.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = files.resolve_legacy(path)?;
|
||||
// Go ignores a missing/unreadable cover while applying tags.
|
||||
let mut file = match path.open(OpenOptions::new().read(true)) {
|
||||
Ok(file) => file,
|
||||
Err(error) if required => return Err(format!("read cover art: {error}")),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if file.metadata().map_err(|e| e.to_string())?.len() > 64 * 1024 * 1024 {
|
||||
return Err("cover exceeds 64 MiB".into());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0; 65536];
|
||||
loop {
|
||||
check()?;
|
||||
let count = file.read(&mut buffer).map_err(|e| e.to_string())?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
if data.len() + count > 64 * 1024 * 1024 {
|
||||
return Err("cover exceeds 64 MiB".into());
|
||||
}
|
||||
data.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
if required && data.is_empty() {
|
||||
return Err("cover art is empty".into());
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use crate::host::decode_go_utf8;
|
||||
use base64::{
|
||||
Engine, alphabet,
|
||||
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD},
|
||||
};
|
||||
use rquickjs::{Object, TypedArray, Value};
|
||||
|
||||
pub(crate) fn option_string(
|
||||
options: Option<&Object<'_>>,
|
||||
key: &str,
|
||||
fallback: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(options) = options else {
|
||||
return Ok(fallback.into());
|
||||
};
|
||||
let value: Value = options.get(key).map_err(|error| error.to_string())?;
|
||||
if let Some(value) = value.as_string() {
|
||||
let value = value.to_string().map_err(|error| error.to_string())?;
|
||||
if !value.trim().is_empty() {
|
||||
return Ok(value.trim().into());
|
||||
}
|
||||
} else if let Ok(value) = TypedArray::<u8>::from_value(value) {
|
||||
let bytes = copy_typed(&value);
|
||||
if !bytes.is_empty() {
|
||||
return Ok(decode_go_utf8(&bytes));
|
||||
}
|
||||
}
|
||||
Ok(fallback.into())
|
||||
}
|
||||
|
||||
pub(crate) fn option_i64(options: &Object<'_>, key: &str, fallback: i64) -> Result<i64, String> {
|
||||
let value: Value = options.get(key).map_err(|error| error.to_string())?;
|
||||
if let Some(number) = value.as_number() {
|
||||
return Ok(go_float_i64(number));
|
||||
}
|
||||
if let Some(value) = value.as_string() {
|
||||
let value = value.to_string().map_err(|error| error.to_string())?;
|
||||
let value = value.trim();
|
||||
let prefix: String = value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.take_while(|(index, ch)| {
|
||||
ch.is_ascii_digit() || (*index == 0 && (*ch == '+' || *ch == '-'))
|
||||
})
|
||||
.map(|(_, ch)| ch)
|
||||
.collect();
|
||||
return Ok(prefix.parse().unwrap_or(fallback));
|
||||
}
|
||||
Ok(fallback)
|
||||
}
|
||||
|
||||
pub(crate) fn option_bool(options: &Object<'_>, key: &str, fallback: bool) -> Result<bool, String> {
|
||||
let value: Value = options.get(key).map_err(|error| error.to_string())?;
|
||||
if let Some(value) = value.as_bool() {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = value.as_number() {
|
||||
return Ok(value != 0.0);
|
||||
}
|
||||
if let Some(value) = value.as_string() {
|
||||
return Ok(
|
||||
match value
|
||||
.to_string()
|
||||
.map_err(|error| error.to_string())?
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => fallback,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(fallback)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_string(value: &str, encoding: &str) -> Result<Vec<u8>, String> {
|
||||
match encoding.trim().to_lowercase().as_str() {
|
||||
"" | "utf8" | "utf-8" | "text" => Ok(value.as_bytes().to_vec()),
|
||||
"base64" => {
|
||||
decode_base64(value.trim()).map_err(|error| format!("invalid base64 data: {error}"))
|
||||
}
|
||||
"hex" => {
|
||||
let value = value.trim().as_bytes();
|
||||
let mut bytes = Vec::with_capacity(value.len() / 2);
|
||||
let digit = |byte: u8| {
|
||||
(byte as char)
|
||||
.to_digit(16)
|
||||
.map(|value| value as u8)
|
||||
.ok_or_else(|| format!("invalid hex data: invalid byte 0x{byte:02x}"))
|
||||
};
|
||||
for pair in value.chunks(2) {
|
||||
let first = digit(pair[0])?;
|
||||
if pair.len() < 2 {
|
||||
return Err("invalid hex data: encoding/hex: odd length hex string".into());
|
||||
}
|
||||
bytes.push(first * 16 + digit(pair[1])?);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
_ => Err(format!("unsupported byte encoding: {encoding}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_base64(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
GeneralPurpose::new(
|
||||
&alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
|
||||
)
|
||||
.decode(value.replace(['\r', '\n'], ""))
|
||||
}
|
||||
|
||||
pub(crate) fn decode_option_bytes(
|
||||
options: Option<&Object<'_>>,
|
||||
key: &str,
|
||||
encoding: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if let Some(options) = options {
|
||||
let value: Value = options.get(key).map_err(|error| error.to_string())?;
|
||||
if let Ok(value) = TypedArray::<u8>::from_value(value) {
|
||||
// runtimeOptionString converts []byte to a Go string without UTF-8
|
||||
// replacement or trimming. Preserve arbitrary binary keys and IVs.
|
||||
if matches!(
|
||||
encoding.trim().to_lowercase().as_str(),
|
||||
"" | "utf8" | "utf-8" | "text"
|
||||
) {
|
||||
return Ok(copy_typed(&value));
|
||||
}
|
||||
}
|
||||
}
|
||||
decode_string(&option_string(options, key, "")?, encoding)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(bytes: &[u8], encoding: &str) -> Result<String, String> {
|
||||
match encoding.trim().to_lowercase().as_str() {
|
||||
"" | "base64" => Ok(STANDARD.encode(bytes)),
|
||||
"hex" => {
|
||||
use std::fmt::Write;
|
||||
let mut value = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(value, "{byte:02x}");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
"utf8" | "utf-8" | "text" => Ok(decode_go_utf8(bytes)),
|
||||
_ => Err(format!("unsupported byte encoding: {encoding}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_value(value: Value<'_>, encoding: &str) -> Result<Vec<u8>, String> {
|
||||
if let Some(text) = value.as_string() {
|
||||
return decode_string(
|
||||
&text.to_string().map_err(|error| error.to_string())?,
|
||||
encoding,
|
||||
);
|
||||
}
|
||||
if let Ok(bytes) = TypedArray::<u8>::from_value(value.clone()) {
|
||||
return Ok(copy_typed(&bytes));
|
||||
}
|
||||
if let Some(array) = value.as_array() {
|
||||
return array
|
||||
.iter::<Value>()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let value = item.map_err(|error| error.to_string())?;
|
||||
let number = value
|
||||
.as_number()
|
||||
.ok_or_else(|| format!("unsupported byte array item at index {index}"))?;
|
||||
// Goja exports integral values within int64 as int64. Other
|
||||
// numbers use Go's float64 -> native int conversion first.
|
||||
if number.fract() == 0.0
|
||||
&& (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&number)
|
||||
{
|
||||
return Ok(number as i64 as u8);
|
||||
}
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
return Ok(number as isize as u8);
|
||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
Ok(go_float_i64(number) as u8)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Err("unsupported byte payload type".into())
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn copy_typed(bytes: &TypedArray<'_, u8>) -> Vec<u8> {
|
||||
// SAFETY: only this worker can access the VM. Finish the owned copy before
|
||||
// any engine call, getter, callback, or operation that can detach a buffer.
|
||||
unsafe { bytes.as_bytes() }.unwrap_or_default().to_vec()
|
||||
}
|
||||
|
||||
fn go_float_i64(number: f64) -> i64 {
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
return number as i64;
|
||||
#[cfg(target_arch = "arm")]
|
||||
{
|
||||
// Go ARM32 uses runtime._d2v for int64 conversions, while byte-array
|
||||
// conversion above uses the hardware's native int32 instruction.
|
||||
let bits = number.to_bits();
|
||||
let shift = ((bits >> 52) & 0x7ff) as i32 - 1075;
|
||||
let mantissa = (bits & ((1_u64 << 52) - 1)) | (1_u64 << 52);
|
||||
let magnitude = if shift < -63 {
|
||||
0
|
||||
} else if shift < 0 {
|
||||
mantissa >> -shift
|
||||
} else if shift <= 11 {
|
||||
mantissa << shift
|
||||
} else {
|
||||
u64::from(number as u32) << 32
|
||||
};
|
||||
return if number.is_sign_negative() {
|
||||
magnitude.wrapping_neg() as i64
|
||||
} else {
|
||||
magnitude as i64
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
if number.is_finite()
|
||||
&& (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&number)
|
||||
{
|
||||
number as i64
|
||||
} else {
|
||||
i64::MIN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Block modes retained for the existing extension API and media transforms.
|
||||
|
||||
use aes::cipher::{Block, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub enum BlockCipher {
|
||||
Aes128(Box<aes::Aes128>),
|
||||
Aes192(Box<aes::Aes192>),
|
||||
Aes256(Box<aes::Aes256>),
|
||||
Blowfish(Box<blowfish::Blowfish>),
|
||||
}
|
||||
|
||||
impl BlockCipher {
|
||||
pub fn new(algorithm: &str, key: &[u8]) -> Result<Self, String> {
|
||||
Ok(match algorithm {
|
||||
"aes" => match key.len() {
|
||||
16 => Self::Aes128(Box::new(
|
||||
aes::Aes128::new_from_slice(key).expect("AES-128 key"),
|
||||
)),
|
||||
24 => Self::Aes192(Box::new(
|
||||
aes::Aes192::new_from_slice(key).expect("AES-192 key"),
|
||||
)),
|
||||
32 => Self::Aes256(Box::new(
|
||||
aes::Aes256::new_from_slice(key).expect("AES-256 key"),
|
||||
)),
|
||||
size => return Err(format!("crypto/aes: invalid key size {size}")),
|
||||
},
|
||||
"blowfish" => {
|
||||
if key.is_empty() || key.len() > 56 {
|
||||
return Err(format!("crypto/blowfish: invalid key size {}", key.len()));
|
||||
}
|
||||
// Go accepts 1–56 bytes; RustCrypto starts at four. Repeating a
|
||||
// short key preserves Blowfish's cyclic key expansion exactly.
|
||||
let key = Zeroizing::new(if key.len() < 4 {
|
||||
key.repeat(4_usize.div_ceil(key.len()))
|
||||
} else {
|
||||
key.to_vec()
|
||||
});
|
||||
Self::Blowfish(Box::new(
|
||||
blowfish::Blowfish::new_from_slice(&key).expect("Blowfish key"),
|
||||
))
|
||||
}
|
||||
_ => return Err(format!("unsupported block cipher algorithm: {algorithm}")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn block_size(&self) -> usize {
|
||||
if matches!(self, Self::Blowfish(_)) {
|
||||
8
|
||||
} else {
|
||||
16
|
||||
}
|
||||
}
|
||||
|
||||
/// CBC requires aligned input; CTR increments the entire big-endian counter
|
||||
/// and can transform a partial final block. Input is an owned Rust snapshot.
|
||||
pub fn transform(
|
||||
&self,
|
||||
data: &mut [u8],
|
||||
iv: &[u8],
|
||||
mode: &str,
|
||||
decrypt: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let size = self.block_size();
|
||||
if iv.len() != size {
|
||||
return Err(format!("iv must be {size} bytes"));
|
||||
}
|
||||
if mode != "cbc" && mode != "ctr" {
|
||||
return Err(format!("unsupported block cipher mode: {mode}"));
|
||||
}
|
||||
if mode == "cbc" && !data.len().is_multiple_of(size) {
|
||||
return Err(format!("input length must be a multiple of {size} bytes"));
|
||||
}
|
||||
match self {
|
||||
Self::Aes128(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
|
||||
Self::Aes192(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
|
||||
Self::Aes256(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
|
||||
Self::Blowfish(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transform<C: BlockEncrypt + BlockDecrypt>(
|
||||
cipher: &C,
|
||||
data: &mut [u8],
|
||||
iv: &[u8],
|
||||
mode: &str,
|
||||
decrypt: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let mut state = Block::<C>::clone_from_slice(iv);
|
||||
for (index, chunk) in data.chunks_mut(iv.len()).enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
check()?;
|
||||
}
|
||||
if mode == "ctr" {
|
||||
let mut block = state.clone();
|
||||
cipher.encrypt_block(&mut block);
|
||||
for (byte, mask) in chunk.iter_mut().zip(block.iter()) {
|
||||
*byte ^= mask;
|
||||
}
|
||||
for byte in state.iter_mut().rev() {
|
||||
*byte = byte.wrapping_add(1);
|
||||
if *byte != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if decrypt {
|
||||
let encrypted = Block::<C>::clone_from_slice(chunk);
|
||||
let mut block = encrypted.clone();
|
||||
cipher.decrypt_block(&mut block);
|
||||
for ((byte, plain), previous) in chunk.iter_mut().zip(block.iter()).zip(state.iter()) {
|
||||
*byte = plain ^ previous;
|
||||
}
|
||||
state = encrypted;
|
||||
} else {
|
||||
for (byte, previous) in chunk.iter_mut().zip(state.iter()) {
|
||||
*byte ^= previous;
|
||||
}
|
||||
let block = Block::<C>::from_mut_slice(chunk);
|
||||
cipher.encrypt_block(block);
|
||||
state.clone_from(block);
|
||||
}
|
||||
}
|
||||
check()
|
||||
}
|
||||
|
||||
pub fn pad(data: &mut Vec<u8>, block_size: usize) {
|
||||
let count = block_size - data.len() % block_size;
|
||||
data.resize(data.len() + count, count as u8);
|
||||
}
|
||||
|
||||
pub fn unpad(data: &mut Vec<u8>, block_size: usize) -> Result<(), String> {
|
||||
if data.is_empty() || !data.len().is_multiple_of(block_size) {
|
||||
return Err("invalid padded payload length".into());
|
||||
}
|
||||
let count = usize::from(*data.last().expect("nonempty padded data"));
|
||||
if count == 0
|
||||
|| count > block_size
|
||||
|| count > data.len()
|
||||
|| data[data.len() - count..]
|
||||
.iter()
|
||||
.any(|byte| usize::from(*byte) != count)
|
||||
{
|
||||
return Err("invalid PKCS7 padding".into());
|
||||
}
|
||||
data.truncate(data.len() - count);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use crate::{
|
||||
binary,
|
||||
crypto::{self, BlockCipher},
|
||||
host::decode_go_utf8,
|
||||
runtime::Control,
|
||||
storage,
|
||||
};
|
||||
use aes_gcm::aead::{OsRng, rand_core::RngCore};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use rquickjs::{ArrayBuffer, Ctx, Function, Object, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
) -> rquickjs::Result<()> {
|
||||
let text_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"cryptoText",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>, decrypt: bool, data: String, key: String| {
|
||||
let data = Zeroizing::new(data);
|
||||
let key = Zeroizing::new(key);
|
||||
let key = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(key.as_bytes())));
|
||||
let result = (|| {
|
||||
text_control.check().map_err(|error| error.to_string())?;
|
||||
let output = if decrypt {
|
||||
let bytes = Zeroizing::new(
|
||||
binary::decode_base64(&data)
|
||||
.map_err(|_| "invalid base64 ciphertext".to_owned())?,
|
||||
);
|
||||
let plain = storage::decrypt(&bytes, &key)
|
||||
.map_err(|_| "invalid base64 ciphertext".to_owned())?;
|
||||
decode_go_utf8(&plain)
|
||||
} else {
|
||||
STANDARD.encode(
|
||||
storage::encrypt(data.as_bytes(), &key)
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
};
|
||||
text_control.check().map_err(|error| error.to_string())?;
|
||||
Ok(output)
|
||||
})();
|
||||
let object = result_object(&ctx, result.as_ref().err())?;
|
||||
if let Ok(data) = result {
|
||||
object.set("data", data)?;
|
||||
}
|
||||
Ok::<_, rquickjs::Error>(object)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
host.set(
|
||||
"generateKey",
|
||||
Function::new(ctx.clone(), |ctx: Ctx<'js>, length: u32| {
|
||||
let result = (|| {
|
||||
if !(1..=4096).contains(&length) {
|
||||
return Err("key length must be an integer between 1 and 4096 bytes".to_owned());
|
||||
}
|
||||
let mut bytes = Zeroizing::new(vec![0; length as usize]);
|
||||
OsRng
|
||||
.try_fill_bytes(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(bytes)
|
||||
})();
|
||||
let object = result_object(&ctx, result.as_ref().err())?;
|
||||
if let Ok(bytes) = result {
|
||||
object.set("key", STANDARD.encode(&*bytes))?;
|
||||
object.set("hex", binary::encode(&bytes, "hex").expect("hex encoding"))?;
|
||||
}
|
||||
Ok::<_, rquickjs::Error>(object)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"blockTransform",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>, operation: String, data: Value<'js>, options: Value<'js>| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let result = if operation == "segments" {
|
||||
segments(data, options, &check)
|
||||
} else {
|
||||
block(data, options, operation == "decrypt", &check)
|
||||
};
|
||||
let object = result_object(&ctx, result.as_ref().err())?;
|
||||
if let Ok(result) = result {
|
||||
object.set(result.count_name, result.count)?;
|
||||
if result.raw {
|
||||
// Charge retained output to QuickJS's heap; external Rust-owned
|
||||
// ArrayBuffers would bypass the runtime's memory limit.
|
||||
object.set("data", ArrayBuffer::new_copy(ctx.clone(), &*result.bytes)?)?;
|
||||
} else {
|
||||
match binary::encode(&result.bytes, &result.encoding) {
|
||||
Ok(data) => object.set("data", data)?,
|
||||
Err(error) => return result_object(&ctx, Some(&error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(object)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn result_object<'js>(ctx: &Ctx<'js>, error: Option<&String>) -> rquickjs::Result<Object<'js>> {
|
||||
let object = Object::new(ctx.clone())?;
|
||||
object.set("success", error.is_none())?;
|
||||
if let Some(error) = error {
|
||||
object.set("error", error.as_str())?;
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
struct Output {
|
||||
bytes: Zeroizing<Vec<u8>>,
|
||||
encoding: String,
|
||||
raw: bool,
|
||||
count_name: &'static str,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn block(
|
||||
data: Value<'_>,
|
||||
options: Value<'_>,
|
||||
decrypt: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Output, String> {
|
||||
check()?;
|
||||
let options = options.as_object();
|
||||
let text = |key, fallback| binary::option_string(options, key, fallback);
|
||||
let algorithm = text("algorithm", "")?.to_lowercase();
|
||||
let mode = text("mode", "cbc")?.to_lowercase();
|
||||
let input_encoding = text("inputEncoding", "base64")?.to_lowercase();
|
||||
let encoding = text("outputEncoding", "base64")?.to_lowercase();
|
||||
let padding = text("padding", "none")?.to_lowercase();
|
||||
if algorithm.is_empty() {
|
||||
return Err("algorithm is required".into());
|
||||
}
|
||||
let key = Zeroizing::new(
|
||||
binary::decode_option_bytes(options, "key", &text("keyEncoding", "utf8")?)
|
||||
.map_err(|error| format!("invalid key: {error}"))?,
|
||||
);
|
||||
if key.is_empty() {
|
||||
return Err("key is required".into());
|
||||
}
|
||||
let iv = binary::decode_option_bytes(options, "iv", &text("ivEncoding", "utf8")?)
|
||||
.map_err(|error| format!("invalid iv: {error}"))?;
|
||||
if mode != "cbc" && mode != "ctr" {
|
||||
return Err(format!("unsupported block cipher mode: {mode}"));
|
||||
}
|
||||
let mut bytes = Zeroizing::new(binary::decode_value(data, &input_encoding)?);
|
||||
let cipher = BlockCipher::new(&algorithm, &key)?;
|
||||
let size = cipher.block_size();
|
||||
if iv.len() != size {
|
||||
return Err(format!(
|
||||
"{} must be {size} bytes for {algorithm}",
|
||||
if mode == "ctr" { "iv (counter)" } else { "iv" }
|
||||
));
|
||||
}
|
||||
if mode == "cbc" && !decrypt && padding == "pkcs7" {
|
||||
crypto::pad(&mut bytes, size);
|
||||
}
|
||||
cipher.transform(&mut bytes, &iv, &mode, decrypt, check)?;
|
||||
if mode == "cbc" && decrypt && padding == "pkcs7" {
|
||||
crypto::unpad(&mut bytes, size)?;
|
||||
}
|
||||
Ok(Output {
|
||||
bytes,
|
||||
encoding,
|
||||
raw: false,
|
||||
count_name: "block_size",
|
||||
count: size,
|
||||
})
|
||||
}
|
||||
|
||||
fn segments(
|
||||
data: Value<'_>,
|
||||
options: Value<'_>,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Output, String> {
|
||||
check()?;
|
||||
let options = options.as_object().ok_or("options object is required")?;
|
||||
let text = |key, fallback| binary::option_string(Some(options), key, fallback);
|
||||
let algorithm = text("algorithm", "aes")?.to_lowercase();
|
||||
let input_encoding = text("inputEncoding", "base64")?.to_lowercase();
|
||||
let encoding = text("outputEncoding", "base64")?.to_lowercase();
|
||||
let iv_encoding = text("ivEncoding", "base64")?.to_lowercase();
|
||||
let key = Zeroizing::new(
|
||||
binary::decode_option_bytes(Some(options), "key", &text("keyEncoding", "hex")?)
|
||||
.map_err(|error| format!("invalid key: {error}"))?,
|
||||
);
|
||||
if key.is_empty() {
|
||||
return Err("key is required".into());
|
||||
}
|
||||
if algorithm != "aes" && algorithm != "blowfish" {
|
||||
return Err(format!("unsupported algorithm: {algorithm}"));
|
||||
}
|
||||
let cipher = BlockCipher::new(&algorithm, &key)?;
|
||||
let size = cipher.block_size();
|
||||
let raw_input = input_encoding == "bytes" || input_encoding == "raw";
|
||||
let mut bytes = Zeroizing::new(
|
||||
binary::decode_value(data, if raw_input { "" } else { &input_encoding }).map_err(
|
||||
|error| {
|
||||
if raw_input {
|
||||
format!("invalid byte payload: {error}")
|
||||
} else {
|
||||
error
|
||||
}
|
||||
},
|
||||
)?,
|
||||
);
|
||||
let segments: Value = options.get("segments").map_err(|error| error.to_string())?;
|
||||
if segments.is_null() || segments.is_undefined() {
|
||||
return Err("segments array is required".into());
|
||||
}
|
||||
let segments = segments.as_array().ok_or("segments must be an array")?;
|
||||
let mut count = 0;
|
||||
for (index, segment) in segments.iter::<Value>().enumerate() {
|
||||
check()?;
|
||||
let segment = segment.map_err(|error| error.to_string())?;
|
||||
let segment = segment
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("segment {index} is not an object"))?;
|
||||
let offset = binary::option_i64(segment, "offset", -1)?;
|
||||
let length = binary::option_i64(segment, "size", -1)?;
|
||||
if offset < 0 || length < 0 {
|
||||
return Err(format!("segment {index} has invalid offset/size"));
|
||||
}
|
||||
if length == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = offset as u64;
|
||||
let length = length as u64;
|
||||
if offset
|
||||
.checked_add(length)
|
||||
.is_none_or(|end| end > bytes.len() as u64)
|
||||
{
|
||||
return Err(format!(
|
||||
"segment {index} out of bounds (offset={offset} size={length} len={})",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let mut iv = binary::decode_option_bytes(Some(segment), "iv", &iv_encoding)
|
||||
.map_err(|error| format!("segment {index} has invalid iv: {error}"))?;
|
||||
if iv.len() > size {
|
||||
return Err(format!(
|
||||
"segment {index} iv longer than block size ({} > {size})",
|
||||
iv.len()
|
||||
));
|
||||
}
|
||||
iv.resize(size, 0);
|
||||
cipher.transform(
|
||||
&mut bytes[offset as usize..(offset + length) as usize],
|
||||
&iv,
|
||||
"ctr",
|
||||
true,
|
||||
check,
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(Output {
|
||||
bytes,
|
||||
raw: encoding == "bytes" || encoding == "raw",
|
||||
encoding,
|
||||
count_name: "segments_processed",
|
||||
count,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
use super::store::DownloadFile;
|
||||
use super::{
|
||||
DownloadOptions, Failure, fingerprint, header, next_delay, retryable, set_header,
|
||||
status_failure, validator,
|
||||
};
|
||||
use crate::files::FilePath;
|
||||
use spotiflac_network::{HttpRequest, HttpResponse, HttpStream, NetworkSession};
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn download(
|
||||
path: &FilePath,
|
||||
network: &NetworkSession,
|
||||
url: &str,
|
||||
options: DownloadOptions,
|
||||
chunk_size: u64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
wait: &dyn Fn(Duration) -> Result<(), String>,
|
||||
progress: &mut dyn FnMut(u64, i64),
|
||||
) -> Result<serde_json::Value, Failure> {
|
||||
options.item.start();
|
||||
let mut attempts = 0;
|
||||
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
|
||||
let probe = loop {
|
||||
attempts += 1;
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
let response = open(network, url, &options, "bytes=0-1", "", false, check);
|
||||
let failure = match response {
|
||||
Ok(stream) if matches!(stream.response.status, 200 | 206) => break stream.response,
|
||||
Ok(stream) => {
|
||||
let mut failure = status_failure(&stream.response, &options.policy, attempts);
|
||||
failure.error = format!("chunked probe HTTP {}", stream.response.status);
|
||||
failure
|
||||
}
|
||||
Err(error) => Failure::new(
|
||||
"transient_network",
|
||||
format!("chunked probe failed: {error}"),
|
||||
attempts,
|
||||
),
|
||||
};
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
if attempts >= options.policy.max_attempts
|
||||
|| (failure.http_status > 0 && !retryable(failure.http_status))
|
||||
{
|
||||
return Err(failure);
|
||||
}
|
||||
wait(retry_delay(&failure, delay))
|
||||
.map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
delay = next_delay(delay, &options.policy);
|
||||
};
|
||||
let total = total_length(&probe);
|
||||
let validator = validator(&probe);
|
||||
let fingerprint = fingerprint(url);
|
||||
let mut output = DownloadFile::open(
|
||||
path,
|
||||
&fingerprint,
|
||||
options.persistent && !validator.is_empty() && !fingerprint.is_empty(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to create chunked staged file: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
if output.restored
|
||||
&& (output.state.validator != validator
|
||||
|| (output.state.total > 0 && total > 0 && output.state.total != total))
|
||||
{
|
||||
output
|
||||
.restart()
|
||||
.map_err(|error| Failure::new("storage_error", error, 0))?;
|
||||
}
|
||||
output.state.validator = validator.clone();
|
||||
output.state.total = total;
|
||||
options.item.initial(output.state.bytes, total);
|
||||
let reporter = options.item.reporter(output.state.bytes, total);
|
||||
let mut notified = output.state.bytes;
|
||||
let mut completed_chunk = false;
|
||||
let mut buffer = [0; 64 << 10];
|
||||
while output.state.total <= 0 || output.state.bytes < output.state.total as u64 {
|
||||
let mut start = output.state.bytes;
|
||||
let mut end = start.saturating_add(chunk_size - 1);
|
||||
if output.state.total > 0 {
|
||||
end = end.min(output.state.total as u64 - 1);
|
||||
}
|
||||
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
|
||||
let mut full_response = false;
|
||||
let mut chunk_complete = false;
|
||||
let mut failure = Failure::new("transient_network", "chunked transfer failed", attempts);
|
||||
for attempt in 1..=options.policy.max_attempts {
|
||||
attempts += 1;
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
let continuation = attempt == 1 && completed_chunk;
|
||||
let response = open(
|
||||
network,
|
||||
url,
|
||||
&options,
|
||||
&format!("bytes={start}-{end}"),
|
||||
&validator,
|
||||
continuation,
|
||||
check,
|
||||
);
|
||||
let mut stream = match response {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
failure = Failure::new(
|
||||
"transient_network",
|
||||
format!("chunked request at {start} failed: {error}"),
|
||||
attempts,
|
||||
);
|
||||
if attempt == options.policy.max_attempts {
|
||||
break;
|
||||
}
|
||||
wait(delay).map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
delay = next_delay(delay, &options.policy);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = stream.response.status;
|
||||
if !matches!(status, 200 | 206) {
|
||||
failure = status_failure(&stream.response, &options.policy, attempts);
|
||||
failure.error = format!("chunked HTTP {status} at offset {start}");
|
||||
drop(stream);
|
||||
if !retryable(status) || attempt == options.policy.max_attempts {
|
||||
break;
|
||||
}
|
||||
wait(retry_delay(&failure, delay))
|
||||
.map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
delay = next_delay(delay, &options.policy);
|
||||
continue;
|
||||
}
|
||||
let range = header(&stream.response, "Content-Range");
|
||||
if status == 206 && !range.starts_with(&format!("bytes {start}-")) {
|
||||
let mut failure = Failure::new(
|
||||
"integrity_failed",
|
||||
format!("chunked response has unexpected Content-Range {range:?} at {start}"),
|
||||
attempts,
|
||||
);
|
||||
failure.http_status = status;
|
||||
return Err(failure);
|
||||
}
|
||||
let next_validator = super::validator(&stream.response);
|
||||
if !next_validator.is_empty() && !validator.is_empty() && next_validator != validator {
|
||||
return Err(Failure::new(
|
||||
"integrity_failed",
|
||||
"chunked response validator changed during transfer",
|
||||
attempts,
|
||||
));
|
||||
}
|
||||
let length = header(&stream.response, "Content-Length")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(-1);
|
||||
if status == 200 && start > 0 {
|
||||
output.restart().map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to restart ignored range: {error}"),
|
||||
attempts,
|
||||
)
|
||||
})?;
|
||||
output.state.validator = validator.clone();
|
||||
start = 0;
|
||||
if length > 0 {
|
||||
output.state.total = length;
|
||||
}
|
||||
}
|
||||
let mut received = continuation;
|
||||
let body_result = loop {
|
||||
let guard = options
|
||||
.budget
|
||||
.as_ref()
|
||||
.map(|budget| budget.enter(!received));
|
||||
let read = stream.read(&mut buffer, check);
|
||||
drop(guard);
|
||||
match read {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(count) => {
|
||||
received = true;
|
||||
output.file.write_all(&buffer[..count]).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to write chunked output: {error}"),
|
||||
attempts,
|
||||
)
|
||||
})?;
|
||||
output.state.bytes += count as u64;
|
||||
reporter.report(output.state.bytes as i64, output.state.total);
|
||||
if output.state.total > 0
|
||||
&& (output.state.bytes.saturating_sub(notified) >= 128 << 10
|
||||
|| output.state.bytes >= output.state.total as u64)
|
||||
{
|
||||
notified = output.state.bytes;
|
||||
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
|
||||
progress(output.state.bytes, output.state.total);
|
||||
}
|
||||
}
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
drop(stream);
|
||||
let written = output.state.bytes - start;
|
||||
let expected = if status == 206 && length <= 0 {
|
||||
end.saturating_sub(start).saturating_add(1) as i64
|
||||
} else {
|
||||
length
|
||||
};
|
||||
let body_result = body_result.and_then(|()| {
|
||||
if expected > 0 && written != expected as u64 {
|
||||
Err("unexpected EOF".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
if body_result.is_ok() && written > 0 {
|
||||
full_response = status == 200;
|
||||
chunk_complete = true;
|
||||
completed_chunk = true;
|
||||
break;
|
||||
}
|
||||
output.rewind(start).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to roll back incomplete chunk: {error}"),
|
||||
attempts,
|
||||
)
|
||||
})?;
|
||||
if output.state.total > 0 {
|
||||
options.item.set(
|
||||
start as f64 / output.state.total as f64,
|
||||
start,
|
||||
output.state.total,
|
||||
);
|
||||
}
|
||||
failure = Failure::new(
|
||||
"transient_network",
|
||||
body_result.err().map_or_else(
|
||||
|| format!("chunk at {start} was empty"),
|
||||
|error| format!("failed to read chunk at {start}: {error}"),
|
||||
),
|
||||
attempts,
|
||||
);
|
||||
if let Err(error) = check() {
|
||||
output.checkpoint(true);
|
||||
return Err(Failure::new("cancelled", error, attempts));
|
||||
}
|
||||
if attempt == options.policy.max_attempts {
|
||||
break;
|
||||
}
|
||||
wait(delay).map_err(|error| Failure::new("cancelled", error, attempts))?;
|
||||
delay = next_delay(delay, &options.policy);
|
||||
}
|
||||
if !chunk_complete {
|
||||
output.checkpoint(true);
|
||||
return Err(failure);
|
||||
}
|
||||
output.checkpoint(false);
|
||||
if full_response || (output.state.total <= 0 && output.state.bytes - start < chunk_size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if output.state.bytes == 0
|
||||
|| (output.state.total > 0 && output.state.bytes != output.state.total as u64)
|
||||
{
|
||||
return Err(Failure::new(
|
||||
"integrity_failed",
|
||||
format!(
|
||||
"chunked transfer size mismatch: expected {} bytes, wrote {}",
|
||||
output.state.total, output.state.bytes
|
||||
),
|
||||
attempts,
|
||||
));
|
||||
}
|
||||
output.publish(check).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to publish file: {error}"),
|
||||
attempts,
|
||||
)
|
||||
})?;
|
||||
options
|
||||
.item
|
||||
.set(1.0, output.state.bytes, output.state.bytes as i64);
|
||||
Ok(
|
||||
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":attempts}),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open(
|
||||
network: &NetworkSession,
|
||||
url: &str,
|
||||
options: &DownloadOptions,
|
||||
range: &str,
|
||||
validator: &str,
|
||||
continuation: bool,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<HttpStream, String> {
|
||||
let mut headers = options.headers.clone();
|
||||
set_header(&mut headers, "Range", range.to_owned());
|
||||
if !validator.is_empty() {
|
||||
set_header(&mut headers, "If-Range", validator.to_owned());
|
||||
}
|
||||
let user_agent = options
|
||||
.headers
|
||||
.get("User-Agent")
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| options.app_version.user_agent());
|
||||
set_header(&mut headers, "User-Agent", user_agent.clone());
|
||||
let _guard = options
|
||||
.budget
|
||||
.as_ref()
|
||||
.map(|budget| budget.enter(!continuation));
|
||||
network.open_stream(
|
||||
HttpRequest {
|
||||
url: url.to_owned(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers,
|
||||
default_json: false,
|
||||
user_agent,
|
||||
},
|
||||
Duration::from_secs(24 * 60 * 60),
|
||||
Duration::from_secs(60),
|
||||
check,
|
||||
)
|
||||
}
|
||||
|
||||
fn total_length(response: &HttpResponse) -> i64 {
|
||||
if let Some((_, total)) = header(response, "Content-Range").rsplit_once('/') {
|
||||
// Go's chunk probe uses Sscanf rather than the ordinary strict parser.
|
||||
let prefix: String = total
|
||||
.trim_start()
|
||||
.chars()
|
||||
.enumerate()
|
||||
.take_while(|(index, ch)| {
|
||||
ch.is_ascii_digit() || (*index == 0 && (*ch == '-' || *ch == '+'))
|
||||
})
|
||||
.map(|(_, ch)| ch)
|
||||
.collect();
|
||||
if let Ok(total) = prefix.parse() {
|
||||
return total;
|
||||
}
|
||||
}
|
||||
if response.status == 200 {
|
||||
header(response, "Content-Length").parse().unwrap_or(-1)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_delay(failure: &Failure, fallback: Duration) -> Duration {
|
||||
if failure.retry_after_seconds > 0 {
|
||||
Duration::from_secs(failure.retry_after_seconds.into())
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
mod chunked;
|
||||
pub(crate) mod progress;
|
||||
mod segment_store;
|
||||
pub(crate) mod segments;
|
||||
mod store;
|
||||
|
||||
use crate::files::{ExtensionFiles, FilePath};
|
||||
use crate::transfer_policy::DownloadTransferPolicy;
|
||||
use aes_gcm::aead::{OsRng, rand_core::RngCore};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_core::app_version::AppVersion;
|
||||
use spotiflac_network::{HttpRequest, HttpResponse, NetworkSession, url::UrlParts};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use store::DownloadFile;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct Failure {
|
||||
success: bool,
|
||||
error: String,
|
||||
error_type: &'static str,
|
||||
attempts: i64,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
http_status: u16,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
retry_after_seconds: u16,
|
||||
}
|
||||
|
||||
fn is_zero(value: &u16) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
/// Go's native exports share this envelope, including file-only operations.
|
||||
/// It is distinct from the JS SDK transfer failure payload above.
|
||||
pub(crate) fn native_error_response(message: &str) -> serde_json::Value {
|
||||
let lower = message.to_lowercase();
|
||||
let kind = [
|
||||
(
|
||||
"isp_blocked",
|
||||
&["isp blocking", "try using vpn", "change dns"][..],
|
||||
),
|
||||
("cancelled", &["cancel"][..]),
|
||||
(
|
||||
"verification_required",
|
||||
&[
|
||||
"verification_required",
|
||||
"session is not authenticated",
|
||||
"signed session is not authenticated",
|
||||
"signed session expired",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"provider_reauth_required",
|
||||
&["byoa_provider_reauth_required", "reauth_provider"][..],
|
||||
),
|
||||
("request_auth_invalid", &["request_auth_invalid"][..]),
|
||||
("provider_auth_failed", &["provider_auth_failed"][..]),
|
||||
("provider_unavailable", &["provider_unavailable"][..]),
|
||||
(
|
||||
"rate_limit",
|
||||
&[
|
||||
"rate limit",
|
||||
"http 429",
|
||||
"http status 429",
|
||||
"status 429",
|
||||
"429 for ",
|
||||
"429:",
|
||||
"429;",
|
||||
"too many requests",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"permission",
|
||||
&[
|
||||
"permission",
|
||||
"operation not permitted",
|
||||
"access denied",
|
||||
"failed to create file",
|
||||
"failed to create directory",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"not_found",
|
||||
&[
|
||||
"not found",
|
||||
"not available",
|
||||
"no results",
|
||||
"track not found",
|
||||
"all services failed",
|
||||
][..],
|
||||
),
|
||||
("network", &["network", "connection", "timeout", "dial"][..]),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(_, patterns)| patterns.iter().any(|pattern| lower.contains(pattern)))
|
||||
.map_or("unknown", |(kind, _)| kind);
|
||||
serde_json::json!({"success":false,"message":"","error":message,"error_type":kind})
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
pub(crate) fn new(kind: &'static str, error: impl ToString, attempts: i64) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error: error.to_string(),
|
||||
error_type: kind,
|
||||
attempts,
|
||||
http_status: 0,
|
||||
retry_after_seconds: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DownloadOptions {
|
||||
pub item: progress::ItemProgressTarget,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub app_version: AppVersion,
|
||||
pub resume: bool,
|
||||
pub persistent: bool,
|
||||
pub policy: DownloadTransferPolicy,
|
||||
pub budget: Option<Arc<crate::resolution::ResolutionBudget>>,
|
||||
pub chunk_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn download(
|
||||
files: &ExtensionFiles,
|
||||
path: &FilePath,
|
||||
network: &NetworkSession,
|
||||
url: &str,
|
||||
mut options: DownloadOptions,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
wait: &dyn Fn(Duration) -> Result<(), String>,
|
||||
progress: &mut dyn FnMut(u64, i64),
|
||||
) -> Result<serde_json::Value, Failure> {
|
||||
let _guard = files
|
||||
.lock(path, check)
|
||||
.map_err(|error| Failure::new("cancelled", error, 0))?;
|
||||
if let Some(chunk_size) = options.chunk_size {
|
||||
return chunked::download(
|
||||
path, network, url, options, chunk_size, check, wait, progress,
|
||||
);
|
||||
}
|
||||
let caller_range = options
|
||||
.headers
|
||||
.keys()
|
||||
.any(|name| name.eq_ignore_ascii_case("Range"));
|
||||
if caller_range {
|
||||
options.resume = false;
|
||||
options.persistent = false;
|
||||
}
|
||||
let fingerprint = fingerprint(url);
|
||||
let mut output = DownloadFile::open(
|
||||
path,
|
||||
&fingerprint,
|
||||
options.resume && options.persistent && !fingerprint.is_empty(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to create staged file: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
options.item.start();
|
||||
options.item.initial(output.state.bytes, output.state.total);
|
||||
let reporter = options
|
||||
.item
|
||||
.reporter(output.state.bytes, output.state.total);
|
||||
if output.restored
|
||||
&& output.state.bytes > 0
|
||||
&& output.state.total > 0
|
||||
&& output.state.bytes == output.state.total as u64
|
||||
{
|
||||
output.publish(check).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to publish restored download: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
options
|
||||
.item
|
||||
.set(1.0, output.state.bytes, output.state.total);
|
||||
return Ok(
|
||||
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":0,"resumed":true}),
|
||||
);
|
||||
}
|
||||
let mut retry_delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
|
||||
let mut notified = 0_u64;
|
||||
for attempt in 1..=options.policy.max_attempts {
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
let mut range_from = if options.resume && !output.state.validator.is_empty() {
|
||||
output.state.bytes
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut headers = options.headers.clone();
|
||||
if range_from > 0 {
|
||||
set_header(&mut headers, "Range", format!("bytes={range_from}-"));
|
||||
set_header(&mut headers, "If-Range", output.state.validator.clone());
|
||||
}
|
||||
let request = HttpRequest {
|
||||
url: url.to_owned(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers,
|
||||
default_json: false,
|
||||
user_agent: options.app_version.user_agent(),
|
||||
};
|
||||
let charge = options.budget.as_ref().map(|budget| budget.enter(true));
|
||||
let stream = network.open_stream(
|
||||
request,
|
||||
Duration::from_secs(24 * 60 * 60),
|
||||
Duration::from_secs(60),
|
||||
check,
|
||||
);
|
||||
drop(charge);
|
||||
let mut stream = match stream {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
if attempt == options.policy.max_attempts {
|
||||
return Err(Failure::new("transient_network", error, attempt));
|
||||
}
|
||||
if output.state.bytes > 0 && (!options.resume || output.state.validator.is_empty())
|
||||
{
|
||||
output
|
||||
.restart()
|
||||
.map_err(|error| Failure::new("storage_error", error, attempt))?;
|
||||
}
|
||||
wait(retry_delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
retry_delay = next_delay(retry_delay, &options.policy);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let response = &stream.response;
|
||||
let status = response.status;
|
||||
if !(200..300).contains(&status) {
|
||||
let failure = status_failure(response, &options.policy, attempt);
|
||||
let delay = if failure.retry_after_seconds > 0 {
|
||||
Duration::from_secs(failure.retry_after_seconds.into())
|
||||
} else {
|
||||
retry_delay
|
||||
};
|
||||
drop(stream);
|
||||
if !retryable(status) || attempt == options.policy.max_attempts {
|
||||
return Err(failure);
|
||||
}
|
||||
wait(delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
retry_delay = next_delay(retry_delay, &options.policy);
|
||||
continue;
|
||||
}
|
||||
if range_from > 0 && status == 206 {
|
||||
let range = header(response, "Content-Range");
|
||||
if !range.starts_with(&format!("bytes {range_from}-")) {
|
||||
let mut failure = Failure::new(
|
||||
"integrity_failed",
|
||||
format!(
|
||||
"resume failed: unexpected Content-Range {range:?} at {range_from} bytes"
|
||||
),
|
||||
attempt,
|
||||
);
|
||||
failure.http_status = status;
|
||||
return Err(failure);
|
||||
}
|
||||
let validator = validator(response);
|
||||
if !validator.is_empty() && validator != output.state.validator {
|
||||
let mut failure = Failure::new(
|
||||
"integrity_failed",
|
||||
"resume failed: response validator changed",
|
||||
attempt,
|
||||
);
|
||||
failure.http_status = status;
|
||||
return Err(failure);
|
||||
}
|
||||
}
|
||||
if range_from > 0 && status == 200 {
|
||||
output
|
||||
.rewind(0)
|
||||
.map_err(|error| Failure::new("storage_error", error, attempt))?;
|
||||
range_from = 0;
|
||||
}
|
||||
let validator = validator(response);
|
||||
if !validator.is_empty() {
|
||||
output.state.validator = validator;
|
||||
}
|
||||
let length = header(response, "Content-Length")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(-1);
|
||||
output.state.total = if status == 206 && !caller_range {
|
||||
header(response, "Content-Range")
|
||||
.rsplit_once('/')
|
||||
.and_then(|(_, total)| total.parse().ok())
|
||||
.unwrap_or(if length > 0 {
|
||||
(range_from as i64).saturating_add(length)
|
||||
} else {
|
||||
0
|
||||
})
|
||||
} else {
|
||||
length
|
||||
};
|
||||
if output.state.total > 0 {
|
||||
reporter.report(output.state.bytes as i64, output.state.total);
|
||||
}
|
||||
let mut buffer = [0; 64 << 10];
|
||||
let mut received_bytes = false;
|
||||
let body_result = loop {
|
||||
let budget = options
|
||||
.budget
|
||||
.as_ref()
|
||||
.map(|budget| budget.enter(!received_bytes || !matches!(status, 200 | 206)));
|
||||
let read = stream.read(&mut buffer, check);
|
||||
drop(budget);
|
||||
match read {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(count) => {
|
||||
received_bytes = true;
|
||||
if let Err(error) = output.file.write_all(&buffer[..count]) {
|
||||
return Err(Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to write staged file: {error}"),
|
||||
attempt,
|
||||
));
|
||||
}
|
||||
output.state.bytes += count as u64;
|
||||
reporter.report(output.state.bytes as i64, output.state.total);
|
||||
if output.state.total > 0
|
||||
&& (output.state.bytes.saturating_sub(notified) >= 128 << 10
|
||||
|| output.state.bytes >= output.state.total as u64)
|
||||
{
|
||||
notified = output.state.bytes;
|
||||
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
|
||||
progress(output.state.bytes, output.state.total);
|
||||
}
|
||||
output.checkpoint(false);
|
||||
}
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
drop(stream);
|
||||
let body_result = body_result.and_then(|()| {
|
||||
if output.state.total > 0 && output.state.bytes != output.state.total as u64 {
|
||||
Err("unexpected EOF".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
if let Err(error) = body_result {
|
||||
output.checkpoint(true);
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
if attempt == options.policy.max_attempts {
|
||||
return Err(Failure::new(
|
||||
"transient_network",
|
||||
format!("failed to read response: {error}"),
|
||||
attempt,
|
||||
));
|
||||
}
|
||||
if !options.resume || output.state.validator.is_empty() {
|
||||
output
|
||||
.restart()
|
||||
.map_err(|error| Failure::new("storage_error", error, attempt))?;
|
||||
}
|
||||
wait(retry_delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
retry_delay = next_delay(retry_delay, &options.policy);
|
||||
continue;
|
||||
}
|
||||
if output.state.bytes == 0 {
|
||||
return Err(Failure::new(
|
||||
"integrity_failed",
|
||||
"download response was empty",
|
||||
attempt,
|
||||
));
|
||||
}
|
||||
output.publish(check).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to publish file: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
if output.state.total > 0 {
|
||||
options
|
||||
.item
|
||||
.set(1.0, output.state.bytes, output.state.total);
|
||||
} else {
|
||||
options.item.received(output.state.bytes);
|
||||
}
|
||||
return Ok(
|
||||
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":attempt}),
|
||||
);
|
||||
}
|
||||
unreachable!("policy has at least one attempt")
|
||||
}
|
||||
|
||||
fn set_header(headers: &mut BTreeMap<String, String>, name: &str, value: String) {
|
||||
headers.retain(|key, _| !key.eq_ignore_ascii_case(name));
|
||||
headers.insert(name.to_owned(), value);
|
||||
}
|
||||
|
||||
fn header<'a>(response: &'a HttpResponse, name: &str) -> &'a str {
|
||||
response
|
||||
.headers
|
||||
.get(name)
|
||||
.and_then(|values| values.first())
|
||||
.map_or("", String::as_str)
|
||||
}
|
||||
|
||||
fn validator(response: &HttpResponse) -> String {
|
||||
let etag = header(response, "Etag").trim();
|
||||
if !etag.is_empty() && !etag.to_uppercase().starts_with("W/") {
|
||||
etag.to_owned()
|
||||
} else {
|
||||
header(response, "Last-Modified").trim().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn fingerprint(url: &str) -> String {
|
||||
UrlParts::parse(url).map_or(String::new(), |url| {
|
||||
format!(
|
||||
"{:x}",
|
||||
Sha256::digest(format!(
|
||||
"{}://{}{}",
|
||||
url.scheme.to_lowercase(),
|
||||
url.authority().to_lowercase(),
|
||||
url.escaped_path()
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn retryable(status: u16) -> bool {
|
||||
matches!(status, 408 | 425 | 429 | 500..=u16::MAX)
|
||||
}
|
||||
|
||||
fn status_failure(
|
||||
response: &HttpResponse,
|
||||
policy: &DownloadTransferPolicy,
|
||||
attempt: i64,
|
||||
) -> Failure {
|
||||
let status = response.status;
|
||||
let kind = if status == 429 {
|
||||
"rate_limited"
|
||||
} else if policy.refresh_stream_on_status.contains(&i64::from(status)) {
|
||||
"expired_stream"
|
||||
} else if retryable(status) {
|
||||
"transient_network"
|
||||
} else {
|
||||
"http_error"
|
||||
};
|
||||
let mut failure = Failure::new(kind, format!("HTTP error: {status}"), attempt);
|
||||
failure.http_status = status;
|
||||
let value = header(response, "Retry-After");
|
||||
let nanos = if let Ok(seconds) = value.parse::<isize>() {
|
||||
i128::from((seconds as i64).wrapping_mul(1_000_000_000))
|
||||
} else {
|
||||
httpdate::parse_http_date(value)
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |time| {
|
||||
time.as_nanos() as i128
|
||||
- SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as i128
|
||||
})
|
||||
};
|
||||
if nanos > 0 {
|
||||
failure.retry_after_seconds =
|
||||
((nanos.min(120_000_000_000) + 500_000_000) / 1_000_000_000).max(1) as u16;
|
||||
}
|
||||
failure
|
||||
}
|
||||
|
||||
fn next_delay(current: Duration, policy: &DownloadTransferPolicy) -> Duration {
|
||||
let ceiling = current
|
||||
.saturating_mul(2)
|
||||
.min(Duration::from_millis(policy.max_retry_delay_ms as u64));
|
||||
let floor = Duration::from_millis(policy.initial_retry_delay_ms as u64);
|
||||
if ceiling <= floor {
|
||||
return ceiling;
|
||||
}
|
||||
let mut random = [0; 8];
|
||||
if OsRng.try_fill_bytes(&mut random).is_err() {
|
||||
return ceiling;
|
||||
}
|
||||
floor
|
||||
+ Duration::from_nanos(
|
||||
(u64::from_ne_bytes(random) as f64 / u64::MAX as f64
|
||||
* (ceiling - floor).as_nanos() as f64) as u64,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use spotiflac_core::progress::{ItemTransferProgressReporter, ProgressRegistry};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) struct ItemProgressTarget {
|
||||
registry: Arc<ProgressRegistry>,
|
||||
id: String,
|
||||
track: bool,
|
||||
}
|
||||
|
||||
impl ItemProgressTarget {
|
||||
pub fn new(registry: Arc<ProgressRegistry>, id: String, track: bool) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
id,
|
||||
track,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self) {
|
||||
if !self.id.is_empty() {
|
||||
let _ = self.registry.downloading(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, progress: f64, bytes: u64, total: i64) {
|
||||
if self.track && !self.id.is_empty() {
|
||||
let _ = self
|
||||
.registry
|
||||
.set_progress(&self.id, progress, bytes as i64, total);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initial(&self, bytes: u64, total: i64) {
|
||||
if total > 0 {
|
||||
self.set(bytes as f64 / total as f64, bytes, total);
|
||||
} else {
|
||||
self.received(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn received(&self, bytes: u64) {
|
||||
if self.track && !self.id.is_empty() && bytes > 0 {
|
||||
let _ = self.registry.set_received(&self.id, bytes as i64);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reporter(&self, bytes: u64, total: i64) -> ItemTransferProgressReporter {
|
||||
ItemTransferProgressReporter::new(
|
||||
Arc::clone(&self.registry),
|
||||
if self.track { &self.id } else { "" },
|
||||
bytes as i64,
|
||||
total,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use super::store::{private_options, remove_if_present};
|
||||
use crate::files::FilePath;
|
||||
use cap_std::fs::{Dir, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
struct Checkpoint {
|
||||
version: i64,
|
||||
fingerprint: String,
|
||||
next_index: usize,
|
||||
bytes: u64,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
pub(super) struct SegmentOutput {
|
||||
pub parent: Arc<Dir>,
|
||||
target: OsString,
|
||||
pub staged: OsString,
|
||||
checkpoint: OsString,
|
||||
file: File,
|
||||
state: Checkpoint,
|
||||
keep: bool,
|
||||
promoted: bool,
|
||||
saved_bytes: u64,
|
||||
saved_at: Instant,
|
||||
segments: usize,
|
||||
}
|
||||
|
||||
impl SegmentOutput {
|
||||
pub fn open(
|
||||
path: &FilePath,
|
||||
fingerprint: String,
|
||||
segments: usize,
|
||||
keep: bool,
|
||||
) -> io::Result<Self> {
|
||||
let (parent, target) = path.open_parent()?;
|
||||
let mut staged = target.clone();
|
||||
staged.push(".partial");
|
||||
let mut checkpoint = staged.clone();
|
||||
checkpoint.push(".checkpoint.json.segments");
|
||||
for name in [&staged, &checkpoint] {
|
||||
match parent.symlink_metadata(name) {
|
||||
Ok(info) if !info.is_file() => {
|
||||
return Err(io::Error::other(
|
||||
"download staging path is not a regular file",
|
||||
));
|
||||
}
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let state = keep
|
||||
.then(|| {
|
||||
let file = parent
|
||||
.open_with(&checkpoint, private_options().read(true))
|
||||
.ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take((1 << 20) + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() > 1 << 20 {
|
||||
return None;
|
||||
}
|
||||
let state: Checkpoint = serde_json::from_slice(&bytes).ok()?;
|
||||
(state.version == 1
|
||||
&& state.fingerprint == fingerprint
|
||||
&& state.next_index <= segments
|
||||
&& (state.next_index == 0) == (state.bytes == 0))
|
||||
.then_some(state)
|
||||
})
|
||||
.flatten();
|
||||
if state.is_none() {
|
||||
remove_if_present(&parent, &staged)?;
|
||||
remove_if_present(&parent, &checkpoint)?;
|
||||
}
|
||||
let mut file = parent.open_with(
|
||||
&staged,
|
||||
private_options().read(true).write(true).create(true),
|
||||
)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::other(
|
||||
"download staging path is not a regular file",
|
||||
));
|
||||
}
|
||||
// A short file loses segment boundaries; the whole assembled prefix
|
||||
// must be discarded instead of clamping its byte count.
|
||||
let state = state
|
||||
.filter(|state| metadata.len() >= state.bytes)
|
||||
.unwrap_or_else(|| Checkpoint {
|
||||
fingerprint,
|
||||
..Checkpoint::default()
|
||||
});
|
||||
if state.next_index == 0 {
|
||||
remove_if_present(&parent, &checkpoint)?;
|
||||
}
|
||||
file.set_len(state.bytes)?;
|
||||
file.seek(SeekFrom::Start(state.bytes))?;
|
||||
Ok(Self {
|
||||
parent: Arc::new(parent),
|
||||
target,
|
||||
staged,
|
||||
checkpoint,
|
||||
file,
|
||||
saved_bytes: state.bytes,
|
||||
state,
|
||||
keep,
|
||||
promoted: false,
|
||||
saved_at: Instant::now(),
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next_index(&self) -> usize {
|
||||
self.state.next_index
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.state.bytes
|
||||
}
|
||||
|
||||
pub fn append(
|
||||
&mut self,
|
||||
segment: &mut SegmentFile,
|
||||
size: u64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
segment
|
||||
.file
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut buffer = [0; 128 << 10];
|
||||
let mut copied = 0;
|
||||
let result = (|| {
|
||||
loop {
|
||||
check()?;
|
||||
let count = segment
|
||||
.file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
self.file
|
||||
.write_all(&buffer[..count])
|
||||
.map_err(|error| error.to_string())?;
|
||||
copied += count as u64;
|
||||
}
|
||||
if copied != size {
|
||||
return Err("short segment copy".into());
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_ok() {
|
||||
self.state.bytes += copied;
|
||||
self.state.next_index += 1;
|
||||
} else {
|
||||
// Do not checkpoint a half-appended segment on cancellation or I/O failure.
|
||||
let _ = self.file.set_len(self.state.bytes);
|
||||
let _ = self.file.seek(SeekFrom::Start(self.state.bytes));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn checkpoint(&mut self, force: bool) {
|
||||
if !self.keep
|
||||
|| self.state.next_index == 0
|
||||
|| self.state.bytes == 0
|
||||
|| (!force
|
||||
&& self.state.bytes.saturating_sub(self.saved_bytes) < 8 << 20
|
||||
&& self.saved_at.elapsed().as_secs() < 5)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.save().is_ok() {
|
||||
self.saved_bytes = self.state.bytes;
|
||||
self.saved_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&mut self) -> io::Result<()> {
|
||||
self.file.sync_all()?;
|
||||
self.state.version = 1;
|
||||
self.state.updated_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let bytes = serde_json::to_vec(&self.state)?;
|
||||
let mut temporary = self.checkpoint.clone();
|
||||
temporary.push(".tmp");
|
||||
remove_if_present(&self.parent, &temporary)?;
|
||||
let result = (|| {
|
||||
let mut file = self
|
||||
.parent
|
||||
.open_with(&temporary, private_options().write(true).create_new(true))?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
self.parent
|
||||
.rename(&temporary, &self.parent, &self.checkpoint)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = self.parent.remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn publish(&mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
|
||||
self.file.sync_all().map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
self.parent
|
||||
.rename(&self.staged, &self.parent, &self.target)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.promoted = true;
|
||||
let _ = self.parent.remove_file(&self.checkpoint);
|
||||
let _ = self
|
||||
.parent
|
||||
.try_clone()
|
||||
.and_then(|parent| parent.into_std_file().sync_all());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SegmentOutput {
|
||||
fn drop(&mut self) {
|
||||
// Also clean leftovers from a process that stopped before these workers started.
|
||||
for index in 0..self.segments {
|
||||
let _ = self.parent.remove_file(segment_name(&self.staged, index));
|
||||
}
|
||||
if self.promoted || !self.keep {
|
||||
let _ = self.parent.remove_file(&self.checkpoint);
|
||||
if !self.promoted {
|
||||
let _ = self.parent.remove_file(&self.staged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct SegmentFile {
|
||||
parent: Arc<Dir>,
|
||||
name: OsString,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
impl SegmentFile {
|
||||
pub fn open(parent: &Arc<Dir>, staged: &OsString, index: usize) -> io::Result<Self> {
|
||||
let name = segment_name(staged, index);
|
||||
remove_if_present(parent, &name)?;
|
||||
let file = parent.open_with(
|
||||
&name,
|
||||
private_options().read(true).write(true).create_new(true),
|
||||
)?;
|
||||
Ok(Self {
|
||||
parent: Arc::clone(parent),
|
||||
name,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SegmentFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.parent.remove_file(&self.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_name(staged: &OsString, index: usize) -> OsString {
|
||||
let mut name = staged.clone();
|
||||
name.push(format!(".segment.{index:06}"));
|
||||
name
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use super::segment_store::{SegmentFile, SegmentOutput};
|
||||
use super::{DownloadOptions, Failure, header, next_delay, retryable, status_failure};
|
||||
use crate::files::{ExtensionFiles, FilePath};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_network::{HttpRequest, NetworkSession};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Segment {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
struct Completed {
|
||||
file: SegmentFile,
|
||||
size: u64,
|
||||
attempts: i64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn download(
|
||||
files: &ExtensionFiles,
|
||||
path: &FilePath,
|
||||
network: &NetworkSession,
|
||||
segments: &[Segment],
|
||||
options: DownloadOptions,
|
||||
check: &(dyn Fn() -> Result<(), String> + Sync),
|
||||
progress: &mut dyn FnMut(u64, usize, usize),
|
||||
) -> Result<serde_json::Value, Failure> {
|
||||
let _guard = files
|
||||
.lock(path, check)
|
||||
.map_err(|error| Failure::new("cancelled", error, 0))?;
|
||||
let mut hash = Sha256::new();
|
||||
for segment in segments {
|
||||
hash.update(segment.url.as_bytes());
|
||||
hash.update([0]);
|
||||
}
|
||||
let mut output = SegmentOutput::open(
|
||||
path,
|
||||
format!("{:x}", hash.finalize()),
|
||||
segments.len(),
|
||||
options.persistent,
|
||||
)
|
||||
.map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to create segmented output: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
let restored = output.next_index() == segments.len() && output.bytes() > 0;
|
||||
options.item.start();
|
||||
options.item.set(
|
||||
output.next_index() as f64 / segments.len() as f64,
|
||||
output.bytes(),
|
||||
0,
|
||||
);
|
||||
let reporter = options.item.reporter(output.bytes(), 0);
|
||||
if !restored {
|
||||
let mut next_job = output.next_index();
|
||||
let stop = AtomicBool::new(false);
|
||||
let completed = AtomicBool::new(false);
|
||||
let received = AtomicU64::new(output.bytes());
|
||||
let worker_count = (options.policy.max_parallel_segments as usize)
|
||||
.min(segments.len() - output.next_index());
|
||||
let mut first_failure = None;
|
||||
let mut pending = BTreeMap::new();
|
||||
let parent = std::sync::Arc::clone(&output.parent);
|
||||
let staged = output.staged.clone();
|
||||
let (jobs, job_receiver) = mpsc::sync_channel(worker_count);
|
||||
let job_receiver = Mutex::new(job_receiver);
|
||||
// Scoped workers cannot outlive the JS call, its cancellation lease, or
|
||||
// its directory capability. Only this VM thread invokes JS callbacks.
|
||||
thread::scope(|scope| {
|
||||
let (sender, receiver) = mpsc::sync_channel(worker_count);
|
||||
for _ in 0..worker_count {
|
||||
jobs.send(next_job).expect("segment workers not started");
|
||||
next_job += 1;
|
||||
}
|
||||
let mut jobs = (next_job < segments.len()).then_some(jobs);
|
||||
for _ in 0..worker_count {
|
||||
let sender = sender.clone();
|
||||
let parent = &parent;
|
||||
let staged = &staged;
|
||||
let job_receiver = &job_receiver;
|
||||
let stop = &stop;
|
||||
let received = &received;
|
||||
let completed = &completed;
|
||||
let options = &options;
|
||||
let reporter = &reporter;
|
||||
let spawned = thread::Builder::new()
|
||||
.name("extension-segment".into())
|
||||
.stack_size(2 << 20)
|
||||
.spawn_scoped(scope, move || {
|
||||
let check_worker = || {
|
||||
check()?;
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return Err("download cancelled".into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
loop {
|
||||
if check_worker().is_err() {
|
||||
break;
|
||||
}
|
||||
let Ok(index) = job_receiver
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.recv()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let segment = &segments[index];
|
||||
let result = fetch(
|
||||
segment,
|
||||
index,
|
||||
parent,
|
||||
staged,
|
||||
network,
|
||||
options,
|
||||
received,
|
||||
completed,
|
||||
reporter,
|
||||
&check_worker,
|
||||
);
|
||||
let failed = result.is_err();
|
||||
if sender.send((index, result)).is_err() || failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
if let Err(panic) = result {
|
||||
let _ = sender.send((
|
||||
usize::MAX,
|
||||
Err(Failure::new("storage_error", "segment worker panicked", 0)),
|
||||
));
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
first_failure = Some(Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to start segment worker: {error}"),
|
||||
0,
|
||||
));
|
||||
stop.store(true, Ordering::Release);
|
||||
jobs.take();
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(sender);
|
||||
loop {
|
||||
if let Err(error) = check() {
|
||||
first_failure.get_or_insert_with(|| Failure::new("cancelled", error, 0));
|
||||
stop.store(true, Ordering::Release);
|
||||
jobs.take();
|
||||
}
|
||||
let (index, result) = match receiver.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(result) => result,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
match result {
|
||||
Err(error) => {
|
||||
first_failure.get_or_insert(error);
|
||||
stop.store(true, Ordering::Release);
|
||||
jobs.take();
|
||||
}
|
||||
Ok(result) => {
|
||||
pending.insert(index, result);
|
||||
}
|
||||
}
|
||||
while let Some(mut ready) = pending.remove(&output.next_index()) {
|
||||
let index = output.next_index();
|
||||
if let Err(error) = output.append(&mut ready.file, ready.size, check) {
|
||||
first_failure.get_or_insert_with(|| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to append segment {index}: {error}"),
|
||||
ready.attempts,
|
||||
)
|
||||
});
|
||||
stop.store(true, Ordering::Release);
|
||||
jobs.take();
|
||||
break;
|
||||
}
|
||||
output.checkpoint(false);
|
||||
drop(ready);
|
||||
options.item.set(
|
||||
output.next_index() as f64 / segments.len() as f64,
|
||||
received.load(Ordering::Acquire),
|
||||
0,
|
||||
);
|
||||
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
|
||||
progress(
|
||||
received.load(Ordering::Acquire),
|
||||
output.next_index(),
|
||||
segments.len(),
|
||||
);
|
||||
// Only assembly releases a slot: completed files and active
|
||||
// requests together never exceed the worker count.
|
||||
if let Some(sender) = &jobs {
|
||||
if sender.send(next_job).is_err() {
|
||||
jobs.take();
|
||||
} else {
|
||||
next_job += 1;
|
||||
if next_job == segments.len() {
|
||||
jobs.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(error) = first_failure {
|
||||
output.checkpoint(true);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if output.next_index() != segments.len() || output.bytes() == 0 {
|
||||
return Err(Failure::new(
|
||||
"integrity_failed",
|
||||
format!(
|
||||
"segmented transfer incomplete: assembled {} of {} segments",
|
||||
output.next_index(),
|
||||
segments.len()
|
||||
),
|
||||
0,
|
||||
));
|
||||
}
|
||||
output.publish(check).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to publish segmented output: {error}"),
|
||||
0,
|
||||
)
|
||||
})?;
|
||||
options.item.set(1.0, output.bytes(), output.bytes() as i64);
|
||||
let mut result = serde_json::json!({"success":true,"path":path.display(),"size":output.bytes(),"segments":segments.len()});
|
||||
if restored {
|
||||
result["resumed"] = true.into();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fetch(
|
||||
segment: &Segment,
|
||||
index: usize,
|
||||
parent: &std::sync::Arc<cap_std::fs::Dir>,
|
||||
staged: &std::ffi::OsString,
|
||||
network: &NetworkSession,
|
||||
options: &DownloadOptions,
|
||||
received: &AtomicU64,
|
||||
completed: &AtomicBool,
|
||||
reporter: &spotiflac_core::progress::ItemTransferProgressReporter,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Completed, Failure> {
|
||||
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
|
||||
for attempt in 1..=options.policy.max_attempts {
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
let mut file = SegmentFile::open(parent, staged, index).map_err(|error| {
|
||||
Failure::new(
|
||||
"storage_error",
|
||||
format!("failed to create segment file: {error}"),
|
||||
attempt,
|
||||
)
|
||||
})?;
|
||||
let continuation = attempt == 1 && completed.load(Ordering::Acquire);
|
||||
let charge = options
|
||||
.budget
|
||||
.as_ref()
|
||||
.map(|budget| budget.enter(!continuation));
|
||||
let stream = network.open_stream(
|
||||
HttpRequest {
|
||||
url: segment.url.clone(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers: segment.headers.clone(),
|
||||
default_json: false,
|
||||
user_agent: options.app_version.user_agent(),
|
||||
},
|
||||
Duration::from_secs(24 * 60 * 60),
|
||||
Duration::from_secs(60),
|
||||
check,
|
||||
);
|
||||
drop(charge);
|
||||
let mut failure = match stream {
|
||||
Err(error) => Failure::new("transient_network", error, attempt),
|
||||
Ok(mut stream) => {
|
||||
let status = stream.response.status;
|
||||
if !(200..300).contains(&status) {
|
||||
let mut failure = status_failure(&stream.response, &options.policy, attempt);
|
||||
failure.error = format!("segment {index} HTTP error: {status}");
|
||||
if !retryable(status) || attempt == options.policy.max_attempts {
|
||||
return Err(failure);
|
||||
}
|
||||
failure
|
||||
} else {
|
||||
let expected = header(&stream.response, "Content-Length")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(-1);
|
||||
let mut size = 0;
|
||||
let mut buffer = [0; 64 << 10];
|
||||
let result = loop {
|
||||
let charge = options.budget.as_ref().map(|budget| {
|
||||
budget
|
||||
.enter(!matches!(status, 200 | 206) || (size == 0 && !continuation))
|
||||
});
|
||||
let read = stream.read(&mut buffer, check);
|
||||
drop(charge);
|
||||
match read {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(count) => {
|
||||
if let Err(error) = file.file.write_all(&buffer[..count]) {
|
||||
break Err(error.to_string());
|
||||
}
|
||||
size += count as u64;
|
||||
received.fetch_add(count as u64, Ordering::AcqRel);
|
||||
reporter.report(received.load(Ordering::Acquire) as i64, 0);
|
||||
}
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
let result = result.and_then(|()| {
|
||||
if expected > 0 && size != expected as u64 {
|
||||
Err("unexpected EOF".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
if result.is_ok() && size > 0 {
|
||||
completed.store(true, Ordering::Release);
|
||||
return Ok(Completed {
|
||||
file,
|
||||
size,
|
||||
attempts: attempt,
|
||||
});
|
||||
}
|
||||
received.fetch_sub(size, Ordering::AcqRel);
|
||||
let message = result.err().map_or_else(
|
||||
|| format!("segment {index} response was empty"),
|
||||
|error| format!("failed to read segment {index}: {error}"),
|
||||
);
|
||||
Failure::new("transient_network", message, attempt)
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(file);
|
||||
if let Err(error) = check() {
|
||||
failure = Failure::new("cancelled", error, attempt);
|
||||
}
|
||||
if attempt == options.policy.max_attempts || failure.error_type == "cancelled" {
|
||||
return Err(failure);
|
||||
}
|
||||
let retry = if failure.retry_after_seconds > 0 {
|
||||
Duration::from_secs(failure.retry_after_seconds.into())
|
||||
} else {
|
||||
delay
|
||||
};
|
||||
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
|
||||
let began = Instant::now();
|
||||
while began.elapsed() < retry {
|
||||
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
|
||||
thread::sleep(
|
||||
retry
|
||||
.saturating_sub(began.elapsed())
|
||||
.min(Duration::from_millis(10)),
|
||||
);
|
||||
}
|
||||
delay = next_delay(delay, &options.policy);
|
||||
}
|
||||
unreachable!("policy has at least one attempt")
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use crate::files::FilePath;
|
||||
use cap_std::fs::{Dir, File, OpenOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub(super) struct Checkpoint {
|
||||
version: i64,
|
||||
fingerprint: String,
|
||||
pub validator: String,
|
||||
pub bytes: u64,
|
||||
pub total: i64,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
pub(super) struct DownloadFile {
|
||||
parent: Dir,
|
||||
target: OsString,
|
||||
staged: OsString,
|
||||
checkpoint_name: OsString,
|
||||
pub file: File,
|
||||
pub state: Checkpoint,
|
||||
pub restored: bool,
|
||||
keep_partial: bool,
|
||||
promoted: bool,
|
||||
saved_bytes: u64,
|
||||
saved_at: Instant,
|
||||
}
|
||||
|
||||
impl DownloadFile {
|
||||
pub fn open(path: &FilePath, fingerprint: &str, keep_partial: bool) -> Result<Self, String> {
|
||||
let (parent, target) = path.open_parent().map_err(|error| error.to_string())?;
|
||||
let mut staged = target.clone();
|
||||
staged.push(".partial");
|
||||
let mut checkpoint_name = staged.clone();
|
||||
checkpoint_name.push(".checkpoint.json");
|
||||
for name in [&staged, &checkpoint_name] {
|
||||
match parent.symlink_metadata(name) {
|
||||
Ok(info) if !info.is_file() => {
|
||||
return Err("download staging path is not a regular file".into());
|
||||
}
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let state = keep_partial
|
||||
.then(|| {
|
||||
let mut file = parent
|
||||
.open_with(&checkpoint_name, private_options().read(true))
|
||||
.ok()?;
|
||||
let mut data = Vec::new();
|
||||
(&mut file)
|
||||
.take((1 << 20) + 1)
|
||||
.read_to_end(&mut data)
|
||||
.ok()?;
|
||||
if data.len() > 1 << 20 {
|
||||
return None;
|
||||
}
|
||||
let state: Checkpoint = serde_json::from_slice(&data).ok()?;
|
||||
(state.version == 1
|
||||
&& !state.fingerprint.is_empty()
|
||||
&& state.fingerprint == fingerprint
|
||||
&& !state.validator.is_empty()
|
||||
&& state.bytes > 0)
|
||||
.then_some(state)
|
||||
})
|
||||
.flatten();
|
||||
let restored = state.is_some();
|
||||
if !restored {
|
||||
remove_if_present(&parent, &staged).map_err(|error| error.to_string())?;
|
||||
remove_if_present(&parent, &checkpoint_name).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let file = parent
|
||||
.open_with(
|
||||
&staged,
|
||||
private_options().read(true).write(true).create(true),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !file
|
||||
.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("download staging path is not a regular file".into());
|
||||
}
|
||||
let mut result = Self {
|
||||
parent,
|
||||
target,
|
||||
staged,
|
||||
checkpoint_name,
|
||||
file,
|
||||
state: state.unwrap_or_default(),
|
||||
restored,
|
||||
keep_partial,
|
||||
promoted: false,
|
||||
saved_bytes: 0,
|
||||
saved_at: Instant::now(),
|
||||
};
|
||||
result.state.fingerprint = fingerprint.to_owned();
|
||||
result.state.bytes = result.state.bytes.min(
|
||||
result
|
||||
.file
|
||||
.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.len(),
|
||||
);
|
||||
result
|
||||
.file
|
||||
.set_len(result.state.bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
result
|
||||
.file
|
||||
.seek(SeekFrom::Start(result.state.bytes))
|
||||
.map_err(|error| error.to_string())?;
|
||||
result.saved_bytes = result.state.bytes;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn restart(&mut self) -> Result<(), String> {
|
||||
self.rewind(0)?;
|
||||
self.state.validator.clear();
|
||||
remove_if_present(&self.parent, &self.checkpoint_name).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn rewind(&mut self, bytes: u64) -> Result<(), String> {
|
||||
self.file
|
||||
.set_len(bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(bytes))
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.state.bytes = bytes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn checkpoint(&mut self, force: bool) {
|
||||
if !self.keep_partial || self.state.validator.is_empty() || self.state.bytes == 0 {
|
||||
return;
|
||||
}
|
||||
if !force
|
||||
&& self.state.bytes.saturating_sub(self.saved_bytes) < 8 << 20
|
||||
&& self.saved_at.elapsed().as_secs() < 5
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.saved_bytes = self.state.bytes;
|
||||
self.saved_at = Instant::now();
|
||||
// Data reaches stable storage before publishing its checkpoint pointer.
|
||||
// A failed save is retried on the next interval, not on every read.
|
||||
let _ = self.save();
|
||||
}
|
||||
|
||||
fn save(&mut self) -> io::Result<()> {
|
||||
self.file.sync_all()?;
|
||||
self.state.version = 1;
|
||||
self.state.updated_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let bytes = serde_json::to_vec(&self.state)?;
|
||||
let mut temporary = self.checkpoint_name.clone();
|
||||
temporary.push(".tmp");
|
||||
// Remove the directory entry itself; an old symlink is never followed.
|
||||
remove_if_present(&self.parent, &temporary)?;
|
||||
let result = (|| {
|
||||
let mut file = self
|
||||
.parent
|
||||
.open_with(&temporary, private_options().write(true).create_new(true))?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
self.parent
|
||||
.rename(&temporary, &self.parent, &self.checkpoint_name)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = self.parent.remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn publish(&mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
|
||||
self.file.sync_all().map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
self.parent
|
||||
.rename(&self.staged, &self.parent, &self.target)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.promoted = true;
|
||||
let _ = self.parent.remove_file(&self.checkpoint_name);
|
||||
let _ = self
|
||||
.parent
|
||||
.try_clone()
|
||||
.and_then(|parent| parent.into_std_file().sync_all());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DownloadFile {
|
||||
fn drop(&mut self) {
|
||||
if self.promoted || !self.keep_partial {
|
||||
let _ = self.parent.remove_file(&self.checkpoint_name);
|
||||
if !self.promoted {
|
||||
let _ = self.parent.remove_file(&self.staged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn private_options() -> OpenOptions {
|
||||
let mut options = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(
|
||||
(rustix::fs::OFlags::NONBLOCK | rustix::fs::OFlags::NOFOLLOW).bits() as i32,
|
||||
);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
pub(super) fn remove_if_present(parent: &Dir, name: &OsString) -> io::Result<()> {
|
||||
match parent.remove_file(name) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use crate::download::{self, DownloadOptions};
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use rquickjs::{Ctx, Function, Object, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
register_segments(ctx, host, Arc::clone(&control), services)?;
|
||||
let Some(files) = &services.files else {
|
||||
return Ok(());
|
||||
};
|
||||
let files = Arc::clone(files);
|
||||
let downloads = Arc::clone(&services.downloads);
|
||||
let network = services.network.clone();
|
||||
let policy = services.transfer_policy.clone();
|
||||
let app_version = services.app_version.clone();
|
||||
host.set(
|
||||
"downloadCall",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>,
|
||||
url: String,
|
||||
path: String,
|
||||
options: Value<'js>,
|
||||
headers: String| {
|
||||
let result: Result<serde_json::Value, String> = (|| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
check()?;
|
||||
let network = network.as_ref().ok_or("network access unavailable")?;
|
||||
network.validate_url(&url)?;
|
||||
let path = files.resolve(&path)?;
|
||||
let options = options.as_object();
|
||||
let value = |key: &str| -> Result<Value<'js>, String> {
|
||||
options.map_or_else(
|
||||
|| Ok(Value::new_undefined(ctx.clone())),
|
||||
|object| object.get(key).map_err(|error| error.to_string()),
|
||||
)
|
||||
};
|
||||
let chunked = value("chunked")?;
|
||||
let chunk_size = if chunked.as_bool() == Some(true) {
|
||||
Some(1 << 20)
|
||||
} else {
|
||||
chunked
|
||||
.as_number()
|
||||
.filter(|value| *value > 0.0)
|
||||
.map(|value| {
|
||||
let size = value as i64;
|
||||
if size <= 0 { 1 << 20 } else { size as u64 }
|
||||
})
|
||||
};
|
||||
let mut policy = policy.clone();
|
||||
if let Some(attempts) = value("maxAttempts")?
|
||||
.as_number()
|
||||
.filter(|number| *number > 0.0)
|
||||
{
|
||||
policy.max_attempts = (attempts as i64).clamp(1, 8);
|
||||
}
|
||||
let resume = value("resume")?
|
||||
.as_bool()
|
||||
.unwrap_or(policy.resume_policy == "validated");
|
||||
let has_track = options
|
||||
.map(|object| object.contains_key("trackItemBytes"))
|
||||
.transpose()
|
||||
.map_err(|error| error.to_string())?
|
||||
.unwrap_or(false);
|
||||
let track = value(if has_track {
|
||||
"trackItemBytes"
|
||||
} else {
|
||||
"track_item_bytes"
|
||||
})?
|
||||
.as_bool()
|
||||
.unwrap_or(true);
|
||||
let persistent = value("persistentCheckpoint")?
|
||||
.as_bool()
|
||||
.map_or(policy.persistent_checkpoint, |enabled| enabled && resume);
|
||||
let callback = value("onProgress")?;
|
||||
let callback = callback.as_function();
|
||||
let mut progress = |bytes, total| {
|
||||
if let Some(callback) = callback
|
||||
&& callback.call::<_, ()>((bytes, total)).is_err()
|
||||
{
|
||||
// Go ignores download progress callback errors. Clear the
|
||||
// pending JS exception; cancellation is checked by the host.
|
||||
ctx.catch();
|
||||
}
|
||||
};
|
||||
let headers =
|
||||
serde_json::from_str(&headers).map_err(|error| error.to_string())?;
|
||||
Ok(
|
||||
match download::download(
|
||||
&files,
|
||||
&path,
|
||||
network,
|
||||
&url,
|
||||
DownloadOptions {
|
||||
item: download::progress::ItemProgressTarget::new(
|
||||
Arc::clone(&downloads.progress),
|
||||
control.item_id(),
|
||||
track,
|
||||
),
|
||||
headers,
|
||||
// Go snapshots the UA for a chunked transfer;
|
||||
// ordinary retries read the current app version.
|
||||
app_version: if chunk_size.is_some() {
|
||||
app_version.get().into()
|
||||
} else {
|
||||
app_version.clone()
|
||||
},
|
||||
resume,
|
||||
persistent,
|
||||
policy,
|
||||
budget: control.resolution(),
|
||||
chunk_size,
|
||||
},
|
||||
&check,
|
||||
&|duration| {
|
||||
let _charge = control.resolution().map(|budget| budget.enter(true));
|
||||
control.sleep(duration);
|
||||
check()
|
||||
},
|
||||
&mut progress,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => serde_json::to_value(error).expect("transfer error JSON"),
|
||||
},
|
||||
)
|
||||
})();
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(error) => serde_json::json!({"success":false,"error":error}),
|
||||
}
|
||||
.to_string()
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_segments<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
let Some(files) = &services.files else {
|
||||
return Ok(());
|
||||
};
|
||||
let files = Arc::clone(files);
|
||||
let downloads = Arc::clone(&services.downloads);
|
||||
let network = services.network.clone();
|
||||
let policy = services.transfer_policy.clone();
|
||||
let app_version = services.app_version.clone();
|
||||
host.set(
|
||||
"downloadSegmentsCall",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>, segments: String, path: String, options: Value<'js>| {
|
||||
let result = (|| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
check().map_err(|error| download::Failure::new("cancelled", error, 0))?;
|
||||
let network = network.as_ref().ok_or_else(|| {
|
||||
download::Failure::new("permission", "network access unavailable", 0)
|
||||
})?;
|
||||
let mut segments: Vec<download::segments::Segment> =
|
||||
serde_json::from_str(&segments)
|
||||
.map_err(|error| download::Failure::new("invalid_request", error, 0))?;
|
||||
for (index, segment) in segments.iter_mut().enumerate() {
|
||||
segment.url = segment.url.trim().to_owned();
|
||||
if segment.url.is_empty() {
|
||||
return Err(download::Failure::new(
|
||||
"invalid_request",
|
||||
format!("segment {index} URL is empty"),
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
for segment in &segments {
|
||||
network
|
||||
.validate_url(&segment.url)
|
||||
.map_err(|error| download::Failure::new("permission", error, 0))?;
|
||||
}
|
||||
let path = files
|
||||
.resolve(&path)
|
||||
.map_err(|error| download::Failure::new("permission", error, 0))?;
|
||||
let options = options.as_object();
|
||||
let value = |key: &str| -> Result<Value<'js>, download::Failure> {
|
||||
options.map_or_else(
|
||||
|| Ok(Value::new_undefined(ctx.clone())),
|
||||
|object| {
|
||||
object.get(key).map_err(|error| {
|
||||
download::Failure::new("invalid_request", error, 0)
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut policy = policy.clone();
|
||||
for (name, target) in [
|
||||
("maxAttempts", &mut policy.max_attempts),
|
||||
("maxParallel", &mut policy.max_parallel_segments),
|
||||
] {
|
||||
if let Some(number) = value(name)?.as_number() {
|
||||
let rounded = crate::transfer_policy::rounded_int(number);
|
||||
if rounded > 0 {
|
||||
*target = rounded.clamp(1, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let persistent = value("persistentCheckpoint")?
|
||||
.as_bool()
|
||||
.unwrap_or(policy.persistent_checkpoint);
|
||||
let callback = value("onProgress")?;
|
||||
let callback = callback.as_function();
|
||||
let mut progress = |bytes, completed, count| {
|
||||
if let Some(callback) = callback
|
||||
&& callback
|
||||
.call::<_, ()>((bytes, 0, completed, count))
|
||||
.is_err()
|
||||
{
|
||||
ctx.catch();
|
||||
}
|
||||
};
|
||||
download::segments::download(
|
||||
&files,
|
||||
&path,
|
||||
network,
|
||||
&segments,
|
||||
DownloadOptions {
|
||||
item: download::progress::ItemProgressTarget::new(
|
||||
Arc::clone(&downloads.progress),
|
||||
control.item_id(),
|
||||
true,
|
||||
),
|
||||
headers: Default::default(),
|
||||
app_version: app_version.clone(),
|
||||
resume: false,
|
||||
persistent,
|
||||
policy,
|
||||
budget: control.resolution(),
|
||||
chunk_size: None,
|
||||
},
|
||||
&check,
|
||||
&mut progress,
|
||||
)
|
||||
})();
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(error) => serde_json::to_value(error).expect("transfer error JSON"),
|
||||
}
|
||||
.to_string()
|
||||
},
|
||||
)?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
//! Platform-owned storage and lifecycle context for managed extension runtimes.
|
||||
|
||||
mod index;
|
||||
|
||||
use crate::auth::{AuthRegistry, ExtensionAuth, PendingAuthRequest};
|
||||
use crate::manifest::{ExtensionManifest, ManifestError};
|
||||
use crate::runtime::LoadMode;
|
||||
use crate::signed_session::SignedRegistry;
|
||||
use crate::storage::{
|
||||
ExtensionStore, StorageError, StorageMasterKey, StoreKind, valid_extension_id,
|
||||
};
|
||||
use crate::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
|
||||
use serde_json::{Map, Value};
|
||||
use spotiflac_core::app_version::AppVersion;
|
||||
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry};
|
||||
use spotiflac_core::downloads::DownloadState;
|
||||
use spotiflac_core::progress::ProgressRegistry;
|
||||
use spotiflac_network::{NetworkService, policy::NetworkPermissions};
|
||||
use spotiflac_providers::lyrics::{CallGraph, CallNode, LyricsService};
|
||||
use std::cmp::Ordering as Comparison;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, LazyLock, Mutex, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
// Keep these contract versions aligned with Go's supportedRuntimeFeatures.
|
||||
pub(crate) static SUPPORTED_RUNTIME_FEATURES: LazyLock<BTreeMap<String, isize>> =
|
||||
LazyLock::new(|| {
|
||||
[
|
||||
("signedSession", 3),
|
||||
("sessionRefresh", 1),
|
||||
("sessionGrant", 1),
|
||||
("globalAction", 1),
|
||||
("webviewAuth", 1),
|
||||
("downloadSegments", 1),
|
||||
("patternedFileTransform", 1),
|
||||
("preparedContext", 1),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, version)| (name.to_owned(), version))
|
||||
.collect()
|
||||
});
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EnvironmentError {
|
||||
#[error("extension environment closed")]
|
||||
Closed,
|
||||
#[error("{0}")]
|
||||
Storage(#[from] StorageError),
|
||||
#[error("{0}")]
|
||||
Manifest(#[from] ManifestError),
|
||||
#[error("{0}")]
|
||||
Runtime(#[from] ExtensionError),
|
||||
#[error("{0}")]
|
||||
Gate(String),
|
||||
#[error("network initialization failed: {0}")]
|
||||
Network(#[from] std::io::Error),
|
||||
#[error("{0}")]
|
||||
Auth(String),
|
||||
#[error("{0}")]
|
||||
Index(String),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
pending: usize,
|
||||
runtimes: Vec<Weak<ExtensionRuntime>>,
|
||||
stores: BTreeMap<String, Arc<ExtensionStore>>,
|
||||
lyrics_nodes: BTreeMap<String, CallNode>,
|
||||
}
|
||||
|
||||
/// The master key comes from the native Keychain/Keystore adapter. The directory
|
||||
/// must belong to this backend; Go and Rust must not concurrently own live data.
|
||||
pub struct ExtensionEnvironment {
|
||||
logs: Arc<crate::logging::LogBuffer>,
|
||||
ffmpeg: Arc<crate::ffmpeg::CommandRegistry>,
|
||||
downloads: Arc<spotiflac_core::downloads::DownloadState>,
|
||||
data_directory: PathBuf,
|
||||
data_directory_alias: PathBuf,
|
||||
master_key: Arc<StorageMasterKey>,
|
||||
app_version: AppVersion,
|
||||
network: Arc<NetworkService>,
|
||||
auth: Arc<AuthRegistry>,
|
||||
sessions: Arc<SignedRegistry>,
|
||||
files: Arc<crate::files::FileRegistry>,
|
||||
isrc: Arc<spotiflac_core::isrc::IndexCache>,
|
||||
closed: Arc<AtomicBool>,
|
||||
state: Mutex<State>,
|
||||
idle: Condvar,
|
||||
shutdown_lock: Mutex<()>,
|
||||
manager_owned: AtomicBool,
|
||||
calls: CallGraph,
|
||||
lyrics: OnceLock<Weak<LyricsService>>,
|
||||
}
|
||||
|
||||
impl ExtensionEnvironment {
|
||||
pub fn new(
|
||||
data_directory: &Path,
|
||||
master_key: &str,
|
||||
app_version: &str,
|
||||
) -> Result<Self, EnvironmentError> {
|
||||
// Validate the key before initializing any platform services or files.
|
||||
StorageMasterKey::from_base64(master_key)?;
|
||||
Self::with_network(
|
||||
data_directory,
|
||||
master_key,
|
||||
app_version,
|
||||
NetworkService::new()?,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_network(
|
||||
data_directory: &Path,
|
||||
master_key: &str,
|
||||
app_version: &str,
|
||||
network: Arc<NetworkService>,
|
||||
) -> Result<Self, EnvironmentError> {
|
||||
let master_key = Arc::new(StorageMasterKey::from_base64(master_key)?);
|
||||
if data_directory.as_os_str().is_empty() {
|
||||
return Err(StorageError::InvalidPath.into());
|
||||
}
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(data_directory).map_err(StorageError::from)?;
|
||||
let data_directory_alias =
|
||||
crate::files::clean(&std::path::absolute(data_directory).map_err(StorageError::from)?);
|
||||
let data_directory = fs::canonicalize(data_directory).map_err(StorageError::from)?;
|
||||
let auth = Arc::new(AuthRegistry::default());
|
||||
let sessions = SignedRegistry::new(&data_directory, Arc::clone(&auth));
|
||||
Ok(Self {
|
||||
logs: Arc::new(crate::logging::LogBuffer::default()),
|
||||
ffmpeg: Arc::new(crate::ffmpeg::CommandRegistry::default()),
|
||||
downloads: Arc::new(DownloadState {
|
||||
progress: Arc::new(ProgressRegistry::new()),
|
||||
cancellation: Arc::new(CancellationRegistry::with_waker(
|
||||
CancellationDomain::Download,
|
||||
network.cancellation_waker(),
|
||||
)),
|
||||
}),
|
||||
data_directory,
|
||||
data_directory_alias,
|
||||
master_key,
|
||||
app_version: app_version.into(),
|
||||
network,
|
||||
auth,
|
||||
sessions,
|
||||
files: Arc::new(crate::files::FileRegistry::default()),
|
||||
isrc: Arc::new(spotiflac_core::isrc::IndexCache::default()),
|
||||
closed: Arc::new(AtomicBool::new(false)),
|
||||
state: Mutex::default(),
|
||||
idle: Condvar::new(),
|
||||
shutdown_lock: Mutex::new(()),
|
||||
manager_owned: AtomicBool::new(false),
|
||||
calls: CallGraph::default(),
|
||||
lyrics: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn enter(&self) -> Result<Operation<'_>, EnvironmentError> {
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(EnvironmentError::Closed);
|
||||
}
|
||||
state.pending += 1;
|
||||
Ok(Operation(self))
|
||||
}
|
||||
|
||||
fn store(&self, id: &str) -> Result<Arc<ExtensionStore>, EnvironmentError> {
|
||||
if !valid_extension_id(id) {
|
||||
return Err(StorageError::InvalidExtensionId.into());
|
||||
}
|
||||
let path = self.data_directory.join(id);
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if !metadata.is_dir() => return Err(StorageError::InvalidPath.into()),
|
||||
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
|
||||
return Err(StorageError::from(error).into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(store) = state.stores.get(id) {
|
||||
return Ok(Arc::clone(store));
|
||||
}
|
||||
let store = Arc::new(ExtensionStore::open(
|
||||
&path,
|
||||
id,
|
||||
Some(Arc::clone(&self.master_key)),
|
||||
)?);
|
||||
state.stores.insert(id.to_owned(), Arc::clone(&store));
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn settings(&self, id: &str) -> Result<Map<String, Value>, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.store(id)?.all(StoreKind::Settings)?)
|
||||
}
|
||||
|
||||
pub fn set_setting(&self, id: &str, key: &str, value: Value) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.store(id)?.set(StoreKind::Settings, key, value)?)
|
||||
}
|
||||
|
||||
pub fn remove_setting(&self, id: &str, key: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.store(id)?.remove(StoreKind::Settings, key)?)
|
||||
}
|
||||
|
||||
pub fn set_allow_private_network(&self, allow: bool) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.network.set_allow_private_network(allow);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_network_compatibility_options(
|
||||
&self,
|
||||
allow_http: bool,
|
||||
insecure_tls: bool,
|
||||
) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.network
|
||||
.set_network_compatibility_options(allow_http, insecure_tls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recycle connections without cancelling active requests or changing policy.
|
||||
pub fn cleanup_connections(&self) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.network.reset_connections();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trusted native composition reuses the same transport and network policy.
|
||||
pub fn network_service(&self) -> Result<Arc<NetworkService>, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(Arc::clone(&self.network))
|
||||
}
|
||||
|
||||
pub fn get_app_version(&self) -> Result<String, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.app_version.get())
|
||||
}
|
||||
|
||||
pub fn set_app_version(&self, version: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.app_version
|
||||
.set(version)
|
||||
.map_err(|error| EnvironmentError::Gate(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn shared_app_version(&self) -> AppVersion {
|
||||
self.app_version.clone()
|
||||
}
|
||||
|
||||
pub fn set_allowed_download_directories(
|
||||
&self,
|
||||
directories: &[PathBuf],
|
||||
) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.files
|
||||
.set_allowed_directories(directories)
|
||||
.map_err(StorageError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_settings(
|
||||
&self,
|
||||
id: &str,
|
||||
values: Map<String, Value>,
|
||||
) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.store(id)?.replace(StoreKind::Settings, values)?)
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
&self,
|
||||
manifest_json: &str,
|
||||
source: &str,
|
||||
limits: RuntimeLimits,
|
||||
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(EnvironmentError::Closed);
|
||||
}
|
||||
if self.manager_owned.load(Ordering::Acquire) {
|
||||
return Err(EnvironmentError::Gate(
|
||||
"installed runtimes must be loaded through their extension manager".into(),
|
||||
));
|
||||
}
|
||||
self.load_internal(
|
||||
manifest_json,
|
||||
source,
|
||||
limits,
|
||||
LoadMode::Initialize,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_registered_with_lease(
|
||||
&self,
|
||||
manifest_json: &str,
|
||||
source: &str,
|
||||
limits: RuntimeLimits,
|
||||
mode: LoadMode,
|
||||
lease: Option<Arc<spotiflac_core::cancellation::RequestLease>>,
|
||||
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
|
||||
self.load_internal(manifest_json, source, limits, mode, None, lease)
|
||||
}
|
||||
|
||||
pub(crate) fn load_isolated(
|
||||
&self,
|
||||
manifest_json: &str,
|
||||
source: &str,
|
||||
limits: RuntimeLimits,
|
||||
primary: &ExtensionRuntime,
|
||||
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
|
||||
self.load_internal(
|
||||
manifest_json,
|
||||
source,
|
||||
limits,
|
||||
LoadMode::Register,
|
||||
Some(primary),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn load_internal(
|
||||
&self,
|
||||
manifest_json: &str,
|
||||
source: &str,
|
||||
limits: RuntimeLimits,
|
||||
load_mode: LoadMode,
|
||||
primary: Option<&ExtensionRuntime>,
|
||||
startup_lease: Option<Arc<spotiflac_core::cancellation::RequestLease>>,
|
||||
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
if manifest_json.len() > 1024 * 1024 {
|
||||
return Err(EnvironmentError::Gate(
|
||||
"invalid extension package: manifest.json is too large".to_owned(),
|
||||
));
|
||||
}
|
||||
let manifest = ExtensionManifest::parse(manifest_json)?;
|
||||
validate_gates(
|
||||
&manifest,
|
||||
&self.app_version.get(),
|
||||
&SUPPORTED_RUNTIME_FEATURES,
|
||||
)
|
||||
.map_err(EnvironmentError::Gate)?;
|
||||
let store = self.store(&manifest.name)?;
|
||||
let mut settings = store.all(StoreKind::Settings)?;
|
||||
settings.retain(|key, _| !key.starts_with('_'));
|
||||
let isolated = primary.is_some();
|
||||
let compiled_source = primary.map(|runtime| Arc::clone(&runtime.compiled_source));
|
||||
let network = primary
|
||||
.and_then(ExtensionRuntime::network_session)
|
||||
.unwrap_or_else(|| {
|
||||
self.network.session(
|
||||
NetworkPermissions {
|
||||
domains: manifest.permissions.network.clone().unwrap_or_default(),
|
||||
allow_http: manifest.permissions.allow_http,
|
||||
},
|
||||
network_timeout(manifest.capabilities.get("networkTimeoutSeconds")),
|
||||
)
|
||||
});
|
||||
// Legacy metadata helpers do not require the general file permission.
|
||||
// They receive read access to this sandbox and native-granted roots.
|
||||
let legacy_files = self
|
||||
.files
|
||||
.extension_with_alias(
|
||||
&self.data_directory.join(&manifest.name),
|
||||
&self.data_directory_alias.join(&manifest.name),
|
||||
)
|
||||
.map_err(StorageError::from)?;
|
||||
let services = ExtensionServices {
|
||||
legacy_backend: true,
|
||||
lyrics: self.lyrics.get().cloned().unwrap_or_default(),
|
||||
lyrics_node: self.lyrics_node(&manifest.name, isolated),
|
||||
lyrics_parent: isolated.then(|| self.lyrics_node(&manifest.name, false)),
|
||||
legacy_files: Some(Arc::clone(&legacy_files)),
|
||||
isrc: Arc::clone(&self.isrc),
|
||||
logs: Arc::clone(&self.logs),
|
||||
extension_id: manifest.name.clone(),
|
||||
ffmpeg: Arc::clone(&self.ffmpeg),
|
||||
raw_ffmpeg_stub: manifest
|
||||
.capabilities
|
||||
.get("rawFfmpeg")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
downloads: Arc::clone(&self.downloads),
|
||||
transfer_policy: crate::transfer_policy::DownloadTransferPolicy::from_capabilities(
|
||||
&manifest.capabilities,
|
||||
),
|
||||
storage: manifest.permissions.storage.then_some(store),
|
||||
files: manifest.permissions.file.then_some(legacy_files),
|
||||
auth: manifest.permissions.storage.then(|| {
|
||||
Arc::new(ExtensionAuth::new(
|
||||
&manifest.name,
|
||||
Arc::clone(&self.auth),
|
||||
Arc::clone(&network),
|
||||
self.app_version.clone(),
|
||||
))
|
||||
}),
|
||||
auth_registry: Some(Arc::clone(&self.auth)),
|
||||
session: manifest
|
||||
.signed_session
|
||||
.clone()
|
||||
.map(|config| {
|
||||
self.sessions
|
||||
.session(&manifest.name, config, Arc::clone(&network))
|
||||
})
|
||||
.transpose()
|
||||
.map_err(EnvironmentError::Auth)?,
|
||||
network: Some(network),
|
||||
app_version: self.app_version.clone(),
|
||||
parent_closed: Some(Arc::clone(&self.closed)),
|
||||
startup_lease,
|
||||
initialize_empty_settings: false,
|
||||
load_mode,
|
||||
compiled_source,
|
||||
};
|
||||
let runtime = Arc::new(ExtensionRuntime::load_with_services(
|
||||
source,
|
||||
&Value::Object(settings).to_string(),
|
||||
limits,
|
||||
services,
|
||||
)?);
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
drop(state);
|
||||
runtime.shutdown();
|
||||
return Err(EnvironmentError::Closed);
|
||||
}
|
||||
state.runtimes.retain(|runtime| runtime.strong_count() > 0);
|
||||
state.runtimes.push(Arc::downgrade(&runtime));
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Interrupt every child and in-progress initialization, then join workers.
|
||||
/// Manager-driven normal unloading should run cleanup before this operation.
|
||||
pub fn shutdown(&self) {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
self.app_version.close();
|
||||
self.logs.shutdown();
|
||||
self.ffmpeg.shutdown();
|
||||
self.downloads.shutdown();
|
||||
self.auth.shutdown();
|
||||
self.sessions.shutdown();
|
||||
let _shutdown = self.shutdown_lock.lock().expect("extension shutdown lock");
|
||||
let runtimes = {
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
std::mem::take(&mut state.runtimes)
|
||||
};
|
||||
for runtime in runtimes.into_iter().filter_map(|runtime| runtime.upgrade()) {
|
||||
runtime.shutdown();
|
||||
}
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
while state.pending > 0 {
|
||||
state = self.idle.wait(state).expect("extension environment idle");
|
||||
}
|
||||
state.lyrics_nodes.clear();
|
||||
self.isrc.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionEnvironment {
|
||||
pub(crate) fn attach_lyrics(&self, lyrics: &Arc<LyricsService>) {
|
||||
self.lyrics
|
||||
.set(Arc::downgrade(lyrics))
|
||||
.expect("lyrics owner already configured");
|
||||
}
|
||||
|
||||
pub(crate) fn lyrics_calls(&self) -> CallGraph {
|
||||
self.calls.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn lyrics_node(&self, id: &str, isolated: bool) -> CallNode {
|
||||
if isolated {
|
||||
return self.calls.node();
|
||||
}
|
||||
self.state
|
||||
.lock()
|
||||
.expect("extension environment lock")
|
||||
.lyrics_nodes
|
||||
.entry(id.to_owned())
|
||||
.or_insert_with(|| self.calls.node())
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn attach_manager(&self) {
|
||||
self.manager_owned.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn closed_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.closed)
|
||||
}
|
||||
pub(crate) fn data_directory(&self) -> &Path {
|
||||
&self.data_directory
|
||||
}
|
||||
|
||||
/// Trusted root operations share download grants without widening any VM's
|
||||
/// sandbox. Relative native paths resolve inside the application's data root.
|
||||
pub(crate) fn native_files(&self) -> Result<Arc<crate::files::ExtensionFiles>, String> {
|
||||
if self.is_closed() {
|
||||
return Err("extension environment closed".into());
|
||||
}
|
||||
self.files
|
||||
.extension_with_alias(&self.data_directory, &self.data_directory_alias)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Trusted native callers retain this grant until finalization completes.
|
||||
pub fn grant_temporary_download_directory(
|
||||
&self,
|
||||
path: &Path,
|
||||
) -> Result<crate::files::TemporaryGrant, String> {
|
||||
let _operation = self.enter().map_err(|error| error.to_string())?;
|
||||
self.files
|
||||
.grant_temporary_directory(path)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_post_process_path(
|
||||
&self,
|
||||
id: &str,
|
||||
input: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut bases = vec![self.data_directory.join(id)];
|
||||
// Native platforms may supply a trusted alias such as /var/... while
|
||||
// the store owns /private/var/.... Accept that spelling only while it
|
||||
// still resolves to this extension's actual data directory.
|
||||
let alias = self.data_directory_alias.join(id);
|
||||
if alias != bases[0] && fs::canonicalize(&alias).is_ok_and(|path| path == bases[0]) {
|
||||
bases.push(alias);
|
||||
}
|
||||
if !input.is_empty() {
|
||||
let input = crate::files::clean(Path::new(input));
|
||||
bases.insert(
|
||||
0,
|
||||
input
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.files.validate_native_replacement(path, &bases)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_data(&self, id: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
if !valid_extension_id(id) {
|
||||
return Err(StorageError::InvalidExtensionId.into());
|
||||
}
|
||||
let mut state = self.state.lock().expect("extension environment lock");
|
||||
if let Some(store) = state.stores.remove(id) {
|
||||
store.close();
|
||||
}
|
||||
self.auth.clear(id);
|
||||
self.sessions.forget_extension(id);
|
||||
let path = self.data_directory.join(id);
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.is_dir() => {
|
||||
fs::remove_dir_all(path).map_err(StorageError::from)?
|
||||
}
|
||||
Ok(_) => fs::remove_file(path).map_err(StorageError::from)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(StorageError::from(error).into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_buffer(&self) -> Arc<crate::logging::LogBuffer> {
|
||||
Arc::clone(&self.logs)
|
||||
}
|
||||
|
||||
pub fn repository(
|
||||
&self,
|
||||
directory: &Path,
|
||||
) -> Result<crate::repository::ExtensionRepository, crate::repository::RepositoryError> {
|
||||
let _operation = self
|
||||
.enter()
|
||||
.map_err(|e| crate::repository::RepositoryError(e.to_string()))?;
|
||||
crate::repository::ExtensionRepository::with_network(directory, Arc::clone(&self.network))
|
||||
.map(|repository| repository.attach_environment(Arc::clone(&self.closed)))
|
||||
}
|
||||
|
||||
pub fn ffmpeg_commands(&self) -> Arc<crate::ffmpeg::CommandRegistry> {
|
||||
Arc::clone(&self.ffmpeg)
|
||||
}
|
||||
|
||||
pub fn download_state(&self) -> Arc<spotiflac_core::downloads::DownloadState> {
|
||||
Arc::clone(&self.downloads)
|
||||
}
|
||||
|
||||
pub fn set_runtime_state(&self, raw: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.sessions
|
||||
.set_runtime_state(raw)
|
||||
.map_err(EnvironmentError::Auth)
|
||||
}
|
||||
|
||||
pub fn set_session_grant(&self, id: &str, grant: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.sessions
|
||||
.set_grant(id, grant)
|
||||
.map_err(EnvironmentError::Auth)
|
||||
}
|
||||
|
||||
pub fn pending_auth(&self, id: &str) -> Result<Option<PendingAuthRequest>, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.auth.pending(id.trim()))
|
||||
}
|
||||
|
||||
pub fn all_pending_auth(&self) -> Result<Vec<PendingAuthRequest>, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.auth.all_pending())
|
||||
}
|
||||
|
||||
pub fn clear_pending_auth(&self, id: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.auth.clear_pending(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_callback_state(
|
||||
&self,
|
||||
state: &str,
|
||||
consume: bool,
|
||||
) -> Result<String, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.auth
|
||||
.resolve_callback(state, consume)
|
||||
.map_err(EnvironmentError::Auth)
|
||||
}
|
||||
|
||||
pub fn set_auth_code(&self, id: &str, code: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.auth.set_code(id, code).map_err(EnvironmentError::Auth)
|
||||
}
|
||||
|
||||
pub fn set_auth_tokens(
|
||||
&self,
|
||||
id: &str,
|
||||
access: &str,
|
||||
refresh: &str,
|
||||
expires_in: i64,
|
||||
) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.auth
|
||||
.set_tokens(id, access, refresh, expires_in)
|
||||
.map_err(EnvironmentError::Auth)
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self, id: &str) -> Result<bool, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
Ok(self.auth.authenticated(id))
|
||||
}
|
||||
}
|
||||
|
||||
fn network_timeout(value: Option<&Value>) -> Duration {
|
||||
let seconds = match value {
|
||||
Some(Value::String(value)) => value.trim().parse::<i64>().unwrap_or(0),
|
||||
Some(Value::Number(value)) => value.as_f64().unwrap_or(0.0) as i64,
|
||||
_ => 0,
|
||||
};
|
||||
Duration::from_secs(if seconds <= 0 {
|
||||
30
|
||||
} else {
|
||||
seconds.clamp(5, 300) as u64
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for ExtensionEnvironment {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
struct Operation<'a>(&'a ExtensionEnvironment);
|
||||
impl Drop for Operation<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.0.state.lock().expect("extension environment lock");
|
||||
state.pending -= 1;
|
||||
self.0.idle.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_gates(
|
||||
manifest: &ExtensionManifest,
|
||||
app_version: &str,
|
||||
features: &BTreeMap<String, isize>,
|
||||
) -> Result<(), String> {
|
||||
let minimum = manifest.min_app_version.trim();
|
||||
let installed = app_version.trim();
|
||||
if !minimum.is_empty() && !installed.is_empty() && compare_versions(installed, minimum).is_lt()
|
||||
{
|
||||
return Err(format!(
|
||||
"requires app {minimum} or later (installed: {installed})"
|
||||
));
|
||||
}
|
||||
for raw in &manifest.required_runtime_features {
|
||||
let mut name = raw.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut wanted = 1;
|
||||
if let Some((feature, version)) = name.rsplit_once('@')
|
||||
&& !feature.is_empty()
|
||||
{
|
||||
wanted = version
|
||||
.parse::<isize>()
|
||||
.ok()
|
||||
.filter(|version| *version > 0)
|
||||
.unwrap_or(1);
|
||||
name = feature;
|
||||
}
|
||||
let Some(provided) = features.get(name) else {
|
||||
return Err(format!(
|
||||
"requires runtime feature {} this app build does not provide",
|
||||
serde_json::to_string(name).expect("JSON string")
|
||||
));
|
||||
};
|
||||
if *provided < wanted {
|
||||
return Err(format!(
|
||||
"requires runtime feature {name}@{wanted} (app provides @{provided})"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compare_versions(first: &str, second: &str) -> Comparison {
|
||||
fn parts(version: &str) -> impl Iterator<Item = isize> + '_ {
|
||||
version
|
||||
.strip_prefix('v')
|
||||
.unwrap_or(version)
|
||||
.split('.')
|
||||
.map(|part| {
|
||||
part.parse()
|
||||
.unwrap_or_else(|error: std::num::ParseIntError| match error.kind() {
|
||||
std::num::IntErrorKind::PosOverflow => isize::MAX,
|
||||
std::num::IntErrorKind::NegOverflow => isize::MIN,
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
let mut first = parts(first);
|
||||
let mut second = parts(second);
|
||||
loop {
|
||||
let (left, right) = (first.next(), second.next());
|
||||
if left.is_none() && right.is_none() {
|
||||
return Comparison::Equal;
|
||||
}
|
||||
let result = left.unwrap_or(0).cmp(&right.unwrap_or(0));
|
||||
if !result.is_eq() {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use super::{EnvironmentError, ExtensionEnvironment};
|
||||
use spotiflac_core::isrc::{IndexCache, IndexFiles, NativeFiles, parse_tracks};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync);
|
||||
|
||||
impl ExtensionEnvironment {
|
||||
fn with_index<T>(
|
||||
&self,
|
||||
check: Check<'_>,
|
||||
operation: impl FnOnce(&IndexCache, &NativeFiles, Check<'_>) -> Result<T, String>,
|
||||
) -> Result<T, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
let guarded = || {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
Err("extension environment closed".into())
|
||||
} else {
|
||||
check()
|
||||
}
|
||||
};
|
||||
guarded().map_err(EnvironmentError::Index)?;
|
||||
let result =
|
||||
operation(&self.isrc, &NativeFiles, &guarded).map_err(EnvironmentError::Index)?;
|
||||
guarded().map_err(EnvironmentError::Index)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Trusted native entry points share the SDK's index, while JavaScript uses
|
||||
/// a scoped IndexFiles adapter and revalidates native directory grants.
|
||||
pub fn prebuild_isrc_index(
|
||||
&self,
|
||||
directory: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<(), EnvironmentError> {
|
||||
self.with_index(check, |cache, files, check| {
|
||||
cache.prebuild(directory, files, check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_isrc_exists(
|
||||
&self,
|
||||
directory: &str,
|
||||
isrc: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<String, EnvironmentError> {
|
||||
self.with_index(check, |cache, files, check| {
|
||||
cache.check(directory, isrc, files, check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_to_isrc_index(
|
||||
&self,
|
||||
directory: &str,
|
||||
isrc: &str,
|
||||
path: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<(), EnvironmentError> {
|
||||
self.with_index(check, |cache, files, check| {
|
||||
cache.add(directory, isrc, path, files, check)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_files_exist_parallel(
|
||||
&self,
|
||||
directory: &str,
|
||||
tracks_json: &str,
|
||||
check: Check<'_>,
|
||||
) -> Result<String, EnvironmentError> {
|
||||
self.with_index(check, |cache, files, check| {
|
||||
if tracks_json.len() > 8 * 1024 * 1024 {
|
||||
return Err("tracks JSON exceeds 8 MiB limit".into());
|
||||
}
|
||||
let tracks = parse_tracks(tracks_json)?;
|
||||
let results = cache.check_batch(directory, &tracks, files, check)?;
|
||||
serde_json::to_string(&results).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn invalidate_isrc_cache(&self, directory: &str) -> Result<(), EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
self.isrc.invalidate(directory);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_file_exists(&self, path: &str) -> Result<bool, EnvironmentError> {
|
||||
let _operation = self.enter()?;
|
||||
NativeFiles
|
||||
.stat(path)
|
||||
.map(|stamp| stamp.is_some_and(|stamp| !stamp.directory && stamp.size > 0))
|
||||
.map_err(EnvironmentError::Index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Commands executed by the native FFmpeg pump, scoped to one environment.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static NEXT_COMMAND: AtomicU64 = AtomicU64::new(0);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Command {
|
||||
pub command_id: String,
|
||||
pub extension_id: String,
|
||||
pub arguments: Vec<String>,
|
||||
pub input_path: String,
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CommandResult {
|
||||
pub success: bool,
|
||||
pub output: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
command: Command,
|
||||
claimed: bool,
|
||||
result: Option<CommandResult>,
|
||||
waiter: std::thread::Thread,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
commands: BTreeMap<String, Entry>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CommandRegistry {
|
||||
state: Mutex<State>,
|
||||
changed: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("FFmpeg command registry closed")]
|
||||
pub struct RegistryClosed;
|
||||
|
||||
impl CommandRegistry {
|
||||
pub fn get(&self, id: &str) -> Result<Option<Command>, RegistryClosed> {
|
||||
let state = self.state.lock().expect("FFmpeg state lock");
|
||||
state.check()?;
|
||||
Ok(state.commands.get(id).map(|entry| entry.command.clone()))
|
||||
}
|
||||
|
||||
pub fn pending_json(&self) -> Result<String, RegistryClosed> {
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
state.check()?;
|
||||
Ok(state.pending(false))
|
||||
}
|
||||
|
||||
/// Claims each command at most once, even with multiple native pumps.
|
||||
/// A zero/negative timeout performs a nonblocking claim. Shutdown wakes waits.
|
||||
pub fn wait_pending_json(&self, timeout_ms: i64) -> Result<String, RegistryClosed> {
|
||||
let started = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms.clamp(0, 60_000) as u64);
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
loop {
|
||||
state.check()?;
|
||||
let commands = state.pending(true);
|
||||
let remaining = timeout.saturating_sub(started.elapsed());
|
||||
if commands != "[]" || remaining.is_zero() {
|
||||
return Ok(commands);
|
||||
}
|
||||
state = self
|
||||
.changed
|
||||
.wait_timeout(state, remaining)
|
||||
.expect("FFmpeg pending wait")
|
||||
.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Late or duplicate results cannot complete another command or revive one
|
||||
/// removed by cancellation. The native pump can use get() to stop such work.
|
||||
pub fn complete(&self, id: &str, result: CommandResult) -> Result<bool, RegistryClosed> {
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
state.check()?;
|
||||
let Some(entry) = state.commands.get_mut(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if entry.result.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
entry.result = Some(result);
|
||||
entry.waiter.unpark();
|
||||
self.changed.notify_all();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
state.closed = true;
|
||||
for entry in state.commands.values() {
|
||||
entry.waiter.unpark();
|
||||
}
|
||||
state.commands.clear();
|
||||
self.changed.notify_all();
|
||||
}
|
||||
|
||||
pub(crate) fn execute(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
arguments: Vec<String>,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
control: &crate::runtime::Control,
|
||||
) -> Result<CommandResult, String> {
|
||||
let _wait = control.watch_thread();
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
check()?;
|
||||
let sequence = NEXT_COMMAND.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
|
||||
let id = format!("{extension_id}_{sequence}");
|
||||
let command = Command {
|
||||
command_id: id.clone(),
|
||||
extension_id: extension_id.into(),
|
||||
arguments,
|
||||
input_path,
|
||||
output_path,
|
||||
};
|
||||
{
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
state.check().map_err(|e| e.to_string())?;
|
||||
state.commands.insert(
|
||||
id.clone(),
|
||||
Entry {
|
||||
command,
|
||||
claimed: false,
|
||||
result: None,
|
||||
waiter: std::thread::current(),
|
||||
},
|
||||
);
|
||||
self.changed.notify_all();
|
||||
}
|
||||
let _pending = Pending {
|
||||
registry: self,
|
||||
id: &id,
|
||||
};
|
||||
let started = Instant::now();
|
||||
let mut state = self.state.lock().expect("FFmpeg state lock");
|
||||
loop {
|
||||
check().map_err(|e| format!("FFmpeg command cancelled: {e}"))?;
|
||||
state.check().map_err(|e| e.to_string())?;
|
||||
if state
|
||||
.commands
|
||||
.get(&id)
|
||||
.is_some_and(|entry| entry.result.is_some())
|
||||
{
|
||||
return Ok(state
|
||||
.commands
|
||||
.remove(&id)
|
||||
.expect("completed FFmpeg command")
|
||||
.result
|
||||
.expect("FFmpeg result"));
|
||||
}
|
||||
let remaining = COMMAND_TIMEOUT.saturating_sub(started.elapsed());
|
||||
if remaining.is_zero() {
|
||||
return Err("FFmpeg command timed out".into());
|
||||
}
|
||||
drop(state);
|
||||
std::thread::park_timeout(remaining.min(control.deadline_remaining()));
|
||||
state = self.state.lock().expect("FFmpeg state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn check(&self) -> Result<(), RegistryClosed> {
|
||||
if self.closed {
|
||||
Err(RegistryClosed)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn pending(&mut self, claim: bool) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct PendingCommand<'a> {
|
||||
command_id: &'a str,
|
||||
extension_id: &'a str,
|
||||
arguments: &'a [String],
|
||||
output_path: &'a str,
|
||||
}
|
||||
let pending: Vec<_> = self
|
||||
.commands
|
||||
.values_mut()
|
||||
.filter(|entry| !entry.claimed && entry.result.is_none())
|
||||
.map(|entry| {
|
||||
if claim {
|
||||
entry.claimed = true;
|
||||
}
|
||||
PendingCommand {
|
||||
command_id: &entry.command.command_id,
|
||||
extension_id: &entry.command.extension_id,
|
||||
arguments: &entry.command.arguments,
|
||||
output_path: &entry.command.output_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&pending).expect("FFmpeg pending JSON")
|
||||
}
|
||||
}
|
||||
|
||||
struct Pending<'a> {
|
||||
registry: &'a CommandRegistry,
|
||||
id: &'a str,
|
||||
}
|
||||
|
||||
impl Drop for Pending<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.registry
|
||||
.state
|
||||
.lock()
|
||||
.expect("FFmpeg state lock")
|
||||
.commands
|
||||
.remove(self.id);
|
||||
self.registry.changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommandRegistry {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use crate::files::ExtensionFiles;
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use rquickjs::{Ctx, Function, Object, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
host.set("rawFfmpegStub", services.raw_ffmpeg_stub)?;
|
||||
let Some(files) = &services.files else {
|
||||
return Ok(());
|
||||
};
|
||||
let info_files = Arc::clone(files);
|
||||
let info_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"mediaInfo",
|
||||
Function::new(ctx.clone(), move |ctx: Ctx<'js>, path: String| {
|
||||
let check = || info_control.check().map_err(|e| e.to_string());
|
||||
let result = media_info(&info_files, &path, &check);
|
||||
let object = Object::new(ctx.clone())?;
|
||||
match result {
|
||||
Ok(quality) => {
|
||||
object.set("success", true)?;
|
||||
object.set("bit_depth", quality.bit_depth)?;
|
||||
object.set("sample_rate", quality.sample_rate)?;
|
||||
object.set("total_samples", quality.total_samples)?;
|
||||
object.set(
|
||||
"duration",
|
||||
quality.total_samples as f64 / quality.sample_rate as f64,
|
||||
)?;
|
||||
object.set("codec", quality.codec)?;
|
||||
}
|
||||
Err(error) => {
|
||||
object.set("success", false)?;
|
||||
object.set("error", error)?;
|
||||
}
|
||||
}
|
||||
Ok::<_, rquickjs::Error>(object)
|
||||
})?,
|
||||
)?;
|
||||
let files = Arc::clone(files);
|
||||
let registry = Arc::clone(&services.ffmpeg);
|
||||
let id = services.extension_id.clone();
|
||||
host.set(
|
||||
"mediaConvert",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |input: String, output: String, options: Value<'js>| {
|
||||
let result = (|| {
|
||||
let input = files
|
||||
.resolve(&input)
|
||||
.and_then(|path| path.native_display())
|
||||
.map_err(|e| format!("invalid input path: {e}"))?;
|
||||
let output = files
|
||||
.resolve(&output)
|
||||
.and_then(|path| path.native_display())
|
||||
.map_err(|e| format!("invalid output path: {e}"))?;
|
||||
let arguments = conversion_arguments(&input, &output, options.as_object())?;
|
||||
let budget = control.resolution();
|
||||
let _pause = budget.as_ref().map(|budget| budget.enter(false));
|
||||
registry.execute(&id, arguments, input, output, &control)
|
||||
})();
|
||||
match result {
|
||||
Ok(result) => serde_json::to_string(&result).expect("FFmpeg result JSON"),
|
||||
Err(error) => serde_json::json!({"success":false,"error":error}).to_string(),
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn media_info(
|
||||
files: &ExtensionFiles,
|
||||
path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<spotiflac_core::media::AudioQuality, String> {
|
||||
let path = files.resolve(path)?;
|
||||
let mut file = path
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|e| format!("failed to open file: {e}"))?;
|
||||
spotiflac_core::media::probe_quality(&mut file, check)
|
||||
}
|
||||
|
||||
fn conversion_arguments(
|
||||
input: &str,
|
||||
output: &str,
|
||||
options: Option<&Object<'_>>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut arguments = vec![
|
||||
"-hide_banner".into(),
|
||||
"-nostdin".into(),
|
||||
"-i".into(),
|
||||
input.into(),
|
||||
];
|
||||
if let Some(options) = options {
|
||||
for (key, flag) in [("codec", "-c:a"), ("bitrate", "-b:a")] {
|
||||
let value: Value = options.get(key).map_err(|e| e.to_string())?;
|
||||
if let Some(value) = value.as_string() {
|
||||
let value = value.to_string().map_err(|e| e.to_string())?;
|
||||
if key == "codec" {
|
||||
if !matches!(
|
||||
value.as_str(),
|
||||
"aac"
|
||||
| "alac"
|
||||
| "copy"
|
||||
| "flac"
|
||||
| "libmp3lame"
|
||||
| "libopus"
|
||||
| "opus"
|
||||
| "pcm_s16le"
|
||||
| "pcm_s24le"
|
||||
) {
|
||||
return Err("unsupported audio codec".into());
|
||||
}
|
||||
} else {
|
||||
let digits = value.strip_suffix(['k', 'K', 'm', 'M']).unwrap_or(&value);
|
||||
if digits.is_empty()
|
||||
|| digits.len() > 8
|
||||
|| digits.starts_with('0')
|
||||
|| !digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
return Err("invalid audio bitrate".into());
|
||||
}
|
||||
}
|
||||
arguments.extend([flag.into(), value]);
|
||||
}
|
||||
}
|
||||
for (key, message) in [
|
||||
("sample_rate", "invalid sample rate"),
|
||||
("channels", "invalid channel count"),
|
||||
] {
|
||||
let value: Value = options.get(key).map_err(|e| e.to_string())?;
|
||||
if let Some(value) = value.as_number() {
|
||||
// Goja exports integral JS Numbers as int64; the old host only
|
||||
// accepts float64 here. Preserve its ignored integer options.
|
||||
// Every remaining float is fractional, non-finite, or outside
|
||||
// Goja's integer range, and fails the host's range/integer check.
|
||||
if !value.is_finite()
|
||||
|| value.fract() != 0.0
|
||||
|| (value == 0.0 && value.is_sign_negative())
|
||||
|| !(-9_007_199_254_740_992.0..=9_007_199_254_740_992.0).contains(&value)
|
||||
{
|
||||
return Err(message.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
arguments.extend(["-y".into(), output.into()]);
|
||||
Ok(arguments)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use crate::binary;
|
||||
use crate::files::{ExtensionFiles, MAX_READ, READ_LIMIT_ERROR};
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use rquickjs::{ArrayBuffer, Ctx, Function, Object, Value};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
host.set("filesEnabled", services.files.is_some())?;
|
||||
let Some(files) = &services.files else {
|
||||
return Ok(());
|
||||
};
|
||||
let files = Arc::clone(files);
|
||||
host.set(
|
||||
"fileCall",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>,
|
||||
method: String,
|
||||
path: String,
|
||||
data: Value<'js>,
|
||||
options: Value<'js>,
|
||||
callback: Value<'js>| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let result = call(
|
||||
&ctx, &files, &method, &path, data, options, callback, &check,
|
||||
);
|
||||
if method == "exists" {
|
||||
return Ok::<_, rquickjs::Error>(Value::new_bool(ctx, result.is_ok()));
|
||||
}
|
||||
match result {
|
||||
Ok(object) => Ok(object.into_value()),
|
||||
Err(error) => {
|
||||
let object = Object::new(ctx)?;
|
||||
object.set("success", false)?;
|
||||
object.set("error", error)?;
|
||||
Ok(object.into_value())
|
||||
}
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn call<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
files: &ExtensionFiles,
|
||||
method: &str,
|
||||
path: &str,
|
||||
data: Value<'js>,
|
||||
options: Value<'js>,
|
||||
callback: Value<'js>,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Object<'js>, String> {
|
||||
check()?;
|
||||
let path = files.resolve(path)?;
|
||||
let options = Options(options.as_object());
|
||||
let result = Object::new(ctx.clone()).map_err(js_error)?;
|
||||
result.set("success", true).map_err(js_error)?;
|
||||
match method {
|
||||
"exists" | "getSize" => {
|
||||
let info = path.metadata().map_err(io_error)?;
|
||||
if method == "getSize" {
|
||||
result.set("size", info.len()).map_err(js_error)?;
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
let _guard = files.lock(&path, check)?;
|
||||
path.remove().map_err(io_error)?;
|
||||
}
|
||||
"read" | "readBytes" => {
|
||||
let offset = if method == "read" {
|
||||
0
|
||||
} else {
|
||||
options.int("offset", 0)?
|
||||
};
|
||||
let requested = options.int("length", -1)?;
|
||||
let encoding = options.text("encoding", "base64")?;
|
||||
if offset < 0 {
|
||||
return Err("offset must be >= 0".into());
|
||||
}
|
||||
let mut input = path.open(OpenOptions::new().read(true)).map_err(io_error)?;
|
||||
let size = input.metadata().map_err(io_error)?.len();
|
||||
let offset = (offset as u64).min(size);
|
||||
let mut bytes;
|
||||
if method == "read" {
|
||||
bytes = Vec::new();
|
||||
let mut buffer = [0; 64 << 10];
|
||||
while bytes.len() <= MAX_READ {
|
||||
check()?;
|
||||
let capacity = buffer.len().min(MAX_READ + 1 - bytes.len());
|
||||
let count = input.read(&mut buffer[..capacity]).map_err(io_error)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
if bytes.len() > MAX_READ {
|
||||
return Err(READ_LIMIT_ERROR.into());
|
||||
}
|
||||
result
|
||||
.set("data", crate::host::decode_go_utf8(&bytes))
|
||||
.map_err(js_error)?;
|
||||
} else {
|
||||
input
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("failed to seek file: {error}"))?;
|
||||
let length = if requested < 0 {
|
||||
size - offset
|
||||
} else {
|
||||
requested as u64
|
||||
};
|
||||
if length > MAX_READ as u64 {
|
||||
return Err(READ_LIMIT_ERROR.into());
|
||||
}
|
||||
bytes = vec![0; length.min(size - offset) as usize];
|
||||
let count = read_chunk(&mut input, &mut bytes, check)
|
||||
.map_err(|error| format!("failed to read file: {error}"))?;
|
||||
bytes.truncate(count);
|
||||
if matches!(encoding.trim().to_lowercase().as_str(), "bytes" | "raw") {
|
||||
result
|
||||
.set(
|
||||
"data",
|
||||
ArrayBuffer::new_copy(ctx.clone(), &bytes).map_err(js_error)?,
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
} else {
|
||||
result
|
||||
.set("data", binary::encode(&bytes, &encoding)?)
|
||||
.map_err(js_error)?;
|
||||
}
|
||||
result.set("bytes_read", bytes.len()).map_err(js_error)?;
|
||||
result.set("offset", offset).map_err(js_error)?;
|
||||
result.set("size", size).map_err(js_error)?;
|
||||
result
|
||||
.set("eof", offset + bytes.len() as u64 >= size)
|
||||
.map_err(js_error)?;
|
||||
}
|
||||
}
|
||||
"write" | "writeBytes" => {
|
||||
let append = options.boolean("append", false)?;
|
||||
let truncate = options.boolean("truncate", false)?;
|
||||
let has_offset = options
|
||||
.0
|
||||
.map(|object| object.contains_key("offset"))
|
||||
.transpose()
|
||||
.map_err(js_error)?
|
||||
.unwrap_or(false);
|
||||
let offset = options.int("offset", 0)?;
|
||||
if append && has_offset {
|
||||
return Err("append and offset cannot be used together".into());
|
||||
}
|
||||
if offset < 0 {
|
||||
return Err("offset must be >= 0".into());
|
||||
}
|
||||
let encoding = if method == "write" {
|
||||
"utf8".into()
|
||||
} else {
|
||||
options.text("encoding", "base64")?
|
||||
};
|
||||
let bytes = binary::decode_value(data, &encoding)?;
|
||||
let _guard = files.lock(&path, check)?;
|
||||
path.mkdir_parent()
|
||||
.map_err(|error| format!("failed to create directory: {error}"))?;
|
||||
if method == "write" {
|
||||
let mut output = path.stage().map_err(io_error)?;
|
||||
write_chunks(&mut output.file, &bytes, check)?;
|
||||
output.publish(check)?;
|
||||
} else {
|
||||
let mut output = path
|
||||
.open(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(append)
|
||||
.truncate(truncate),
|
||||
)
|
||||
.map_err(io_error)?;
|
||||
if has_offset {
|
||||
output
|
||||
.seek(SeekFrom::Start(offset as u64))
|
||||
.map_err(|error| format!("failed to seek file: {error}"))?;
|
||||
}
|
||||
write_chunks(&mut output, &bytes, check)?;
|
||||
result.set("bytes_written", bytes.len()).map_err(js_error)?;
|
||||
result
|
||||
.set("size", output.metadata().map_err(io_error)?.len())
|
||||
.map_err(js_error)?;
|
||||
}
|
||||
result.set("path", path.display()).map_err(js_error)?;
|
||||
}
|
||||
"copy" | "move" | "transformPatternedBlocks" => {
|
||||
let destination = data
|
||||
.as_string()
|
||||
.ok_or("destination path is required")?
|
||||
.to_string()
|
||||
.map_err(js_error)?;
|
||||
let destination = files.resolve(&destination)?;
|
||||
if method == "transformPatternedBlocks" {
|
||||
return crate::file_transform::transform(
|
||||
ctx,
|
||||
files,
|
||||
&path,
|
||||
&destination,
|
||||
options,
|
||||
callback,
|
||||
check,
|
||||
);
|
||||
}
|
||||
let _guard = files.lock(&destination, check)?;
|
||||
if method == "move" {
|
||||
destination
|
||||
.mkdir_parent()
|
||||
.map_err(|error| format!("failed to create directory: {error}"))?;
|
||||
check()?;
|
||||
path.rename_to(&destination)
|
||||
.map_err(|error| format!("failed to move file: {error}"))?;
|
||||
} else {
|
||||
let mut input = path
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| format!("failed to read source: {error}"))?;
|
||||
destination
|
||||
.mkdir_parent()
|
||||
.map_err(|error| format!("failed to create directory: {error}"))?;
|
||||
let mut output = destination
|
||||
.open(OpenOptions::new().write(true).create(true).truncate(true))
|
||||
.map_err(|error| format!("failed to open destination: {error}"))?;
|
||||
let mut buffer = [0; 64 << 10];
|
||||
loop {
|
||||
let count = read_chunk(&mut input, &mut buffer, check)
|
||||
.map_err(|error| format!("failed to copy file: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
write_chunks(&mut output, &buffer[..count], check)
|
||||
.map_err(|error| format!("failed to copy file: {error}"))?;
|
||||
}
|
||||
}
|
||||
result
|
||||
.set("path", destination.display())
|
||||
.map_err(js_error)?;
|
||||
}
|
||||
_ => return Err("unknown file operation".into()),
|
||||
}
|
||||
check()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) struct Options<'a, 'js>(pub Option<&'a Object<'js>>);
|
||||
|
||||
impl Options<'_, '_> {
|
||||
pub fn text(&self, key: &str, fallback: &str) -> Result<String, String> {
|
||||
binary::option_string(self.0, key, fallback)
|
||||
}
|
||||
pub fn int(&self, key: &str, fallback: i64) -> Result<i64, String> {
|
||||
self.0.map_or(Ok(fallback), |object| {
|
||||
binary::option_i64(object, key, fallback)
|
||||
})
|
||||
}
|
||||
pub fn boolean(&self, key: &str, fallback: bool) -> Result<bool, String> {
|
||||
self.0.map_or(Ok(fallback), |object| {
|
||||
binary::option_bool(object, key, fallback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_chunk(
|
||||
input: &mut impl Read,
|
||||
buffer: &mut [u8],
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<usize, String> {
|
||||
let mut total = 0;
|
||||
while total < buffer.len() {
|
||||
check()?;
|
||||
let end = buffer.len().min(total + (64 << 10));
|
||||
match input.read(&mut buffer[total..end]) {
|
||||
Ok(0) => break,
|
||||
Ok(count) => total += count,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub(crate) fn write_chunks(
|
||||
output: &mut impl Write,
|
||||
bytes: &[u8],
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
for chunk in bytes.chunks(64 << 10) {
|
||||
check()?;
|
||||
output.write_all(chunk).map_err(io_error)?;
|
||||
}
|
||||
check()
|
||||
}
|
||||
|
||||
pub(crate) fn js_error(error: rquickjs::Error) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
fn io_error(error: std::io::Error) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::binary;
|
||||
use crate::crypto::BlockCipher;
|
||||
use crate::file_host::{Options, js_error, read_chunk, write_chunks};
|
||||
use crate::files::{ExtensionFiles, FilePath, MAX_READ};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use rquickjs::{Coerced, Ctx, FromJs, Object, Value};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn transform<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
files: &ExtensionFiles,
|
||||
input: &FilePath,
|
||||
output: &FilePath,
|
||||
options: Options<'_, 'js>,
|
||||
callback: Value<'js>,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Object<'js>, String> {
|
||||
let algorithm = options.text("algorithm", "")?.to_lowercase();
|
||||
let mode = options.text("mode", "cbc")?.to_lowercase();
|
||||
let padding = options.text("padding", "none")?.to_lowercase();
|
||||
if algorithm.is_empty() {
|
||||
return Err("algorithm is required".into());
|
||||
}
|
||||
let key = Zeroizing::new(
|
||||
binary::decode_option_bytes(options.0, "key", &options.text("keyEncoding", "utf8")?)
|
||||
.map_err(|error| format!("invalid key: {error}"))?,
|
||||
);
|
||||
if key.is_empty() {
|
||||
return Err("key is required".into());
|
||||
}
|
||||
let iv = binary::decode_option_bytes(options.0, "iv", &options.text("ivEncoding", "utf8")?)
|
||||
.map_err(|error| format!("invalid iv: {error}"))?;
|
||||
if mode != "cbc" && mode != "ctr" {
|
||||
return Err(format!("unsupported block cipher mode: {mode}"));
|
||||
}
|
||||
if padding != "none" {
|
||||
return Err("patterned file transforms only support padding: none".into());
|
||||
}
|
||||
let operation = options.text("operation", "decrypt")?.to_lowercase();
|
||||
if operation != "decrypt" && operation != "encrypt" {
|
||||
return Err("operation must be decrypt or encrypt".into());
|
||||
}
|
||||
let segment_size = options.int("segmentSize", 0)?;
|
||||
let every = options.int("transformEvery", 1)?;
|
||||
let offset = options.int("transformOffset", 0)?;
|
||||
let buffer_size = options.int("bufferSize", 1 << 20)?;
|
||||
let partial = options.boolean("transformPartial", false)?;
|
||||
if !(1..=MAX_READ as i64).contains(&segment_size) {
|
||||
return Err(format!(
|
||||
"segmentSize must be between 1 and {MAX_READ} bytes"
|
||||
));
|
||||
}
|
||||
if every <= 0 {
|
||||
return Err("transformEvery must be greater than zero".into());
|
||||
}
|
||||
if offset < 0 || offset >= every {
|
||||
return Err("transformOffset must be between 0 and transformEvery - 1".into());
|
||||
}
|
||||
let buffer_size = buffer_size.clamp(segment_size, MAX_READ as i64);
|
||||
let buffer_size = (buffer_size - buffer_size % segment_size) as usize;
|
||||
let cipher = BlockCipher::new(&algorithm, &key)?;
|
||||
let block_size = cipher.block_size();
|
||||
if iv.len() != block_size {
|
||||
return Err(format!("iv must be {block_size} bytes for {algorithm}"));
|
||||
}
|
||||
if mode == "cbc" && segment_size % block_size as i64 != 0 {
|
||||
return Err(format!(
|
||||
"segmentSize must be a multiple of {block_size} bytes for CBC"
|
||||
));
|
||||
}
|
||||
let callback = if callback.is_null() || callback.is_undefined() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
callback
|
||||
.as_function()
|
||||
.ok_or("progress callback must be a function")?,
|
||||
)
|
||||
};
|
||||
let _guard = files.lock(output, check)?;
|
||||
let mut source = input
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| format!("failed to open input file: {error}"))?;
|
||||
let total = source
|
||||
.metadata()
|
||||
.map_err(|error| format!("failed to stat input file: {error}"))?
|
||||
.len();
|
||||
// Keep the legacy collision check although randomized staging no longer
|
||||
// risks clobbering a caller's existing .transform.partial file.
|
||||
if input.display() == format!("{}.transform.partial", output.display()) {
|
||||
return Err("input path conflicts with transform staging path".into());
|
||||
}
|
||||
output
|
||||
.mkdir_parent()
|
||||
.map_err(|error| format!("failed to create output directory: {error}"))?;
|
||||
let mut staged = output
|
||||
.stage()
|
||||
.map_err(|error| format!("failed to create staged output: {error}"))?;
|
||||
let mut buffer = Zeroizing::new(vec![0; buffer_size]);
|
||||
let (mut processed, mut index, mut transformed) = (0_u64, 0_u64, 0_u64);
|
||||
loop {
|
||||
let count = read_chunk(&mut source, &mut buffer, check)
|
||||
.map_err(|error| format!("failed to read input file: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
for segment in buffer[..count].chunks_mut(segment_size as usize) {
|
||||
check()?;
|
||||
if index % every as u64 == offset as u64
|
||||
&& (segment.len() == segment_size as usize || partial)
|
||||
{
|
||||
if mode == "cbc" && segment.len() % block_size != 0 {
|
||||
return Err(format!(
|
||||
"selected segment {index} is not a multiple of {block_size} bytes"
|
||||
));
|
||||
}
|
||||
// Each selected segment restarts its IV/counter, matching Go.
|
||||
cipher.transform(segment, &iv, &mode, operation == "decrypt", check)?;
|
||||
transformed += 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
write_chunks(&mut staged.file, &buffer[..count], check)
|
||||
.map_err(|error| format!("failed to write transformed file: {error}"))?;
|
||||
processed += count as u64;
|
||||
if let Some(callback) = callback
|
||||
&& let Err(error) = callback.call::<_, ()>((processed, total))
|
||||
{
|
||||
let message = if error.is_exception() {
|
||||
let exception = ctx.catch();
|
||||
match Coerced::<String>::from_js(ctx, exception) {
|
||||
Ok(message) => message.0,
|
||||
Err(_) => {
|
||||
ctx.catch();
|
||||
"JavaScript exception".into()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error.to_string()
|
||||
};
|
||||
return Err(format!("progress callback failed: {message}"));
|
||||
}
|
||||
check()?;
|
||||
}
|
||||
drop(source);
|
||||
staged
|
||||
.publish(check)
|
||||
.map_err(|error| format!("failed to publish transformed file: {error}"))?;
|
||||
let result = Object::new(ctx.clone()).map_err(js_error)?;
|
||||
result.set("success", true).map_err(js_error)?;
|
||||
result.set("path", output.display()).map_err(js_error)?;
|
||||
result.set("bytes_processed", processed).map_err(js_error)?;
|
||||
result.set("segments_processed", index).map_err(js_error)?;
|
||||
result
|
||||
.set("segments_transformed", transformed)
|
||||
.map_err(js_error)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
//! File capabilities granted by the native manager, never by JavaScript.
|
||||
|
||||
mod index;
|
||||
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::{Dir, File, OpenOptions};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Condvar, Mutex, RwLock, Weak};
|
||||
use std::thread::{self, ThreadId};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const MAX_READ: usize = 16 << 20;
|
||||
pub(crate) const READ_LIMIT_ERROR: &str = "file read exceeds 16 MiB limit; use file.readBytes with offset and length to read it in chunks";
|
||||
|
||||
struct Root {
|
||||
path: PathBuf,
|
||||
directory: Dir,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
fn open(path: &Path) -> io::Result<Arc<Self>> {
|
||||
let path = clean(&std::path::absolute(path)?);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
Ok(Arc::new(Self {
|
||||
directory: Dir::open_ambient_dir(&path, ambient_authority())?,
|
||||
path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FileRegistry {
|
||||
allowed: RwLock<Vec<Arc<Root>>>,
|
||||
temporary: RwLock<Vec<Weak<Root>>>,
|
||||
outputs: Mutex<BTreeMap<PathBuf, Weak<OutputLock>>>,
|
||||
}
|
||||
|
||||
/// Retain one host-created download directory while its provider is running.
|
||||
/// Open file capabilities may outlive the grant; future opens may not.
|
||||
pub struct TemporaryGrant {
|
||||
root: Arc<Root>,
|
||||
registry: Arc<FileRegistry>,
|
||||
}
|
||||
|
||||
impl Drop for TemporaryGrant {
|
||||
fn drop(&mut self) {
|
||||
let key = Arc::downgrade(&self.root);
|
||||
self.registry
|
||||
.temporary
|
||||
.write()
|
||||
.expect("temporary file grants lock")
|
||||
.retain(|root| !Weak::ptr_eq(root, &key));
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRegistry {
|
||||
/// Replace the native download-directory grants for future operations.
|
||||
/// Open descriptors keep an in-progress operation bound to its original root.
|
||||
pub fn set_allowed_directories(&self, paths: &[PathBuf]) -> io::Result<()> {
|
||||
let roots = paths
|
||||
.iter()
|
||||
.map(|path| Root::open(path))
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
*self.allowed.write().expect("file grants lock") = roots;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn grant_temporary_directory(
|
||||
self: &Arc<Self>,
|
||||
path: &Path,
|
||||
) -> io::Result<TemporaryGrant> {
|
||||
let root = Root::open(path)?;
|
||||
let mut temporary = self.temporary.write().expect("temporary file grants lock");
|
||||
temporary.retain(|root| root.strong_count() != 0);
|
||||
temporary.push(Arc::downgrade(&root));
|
||||
Ok(TemporaryGrant {
|
||||
root,
|
||||
registry: Arc::clone(self),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extension(self: &Arc<Self>, directory: &Path) -> io::Result<Arc<ExtensionFiles>> {
|
||||
self.extension_with_alias(directory, directory)
|
||||
}
|
||||
|
||||
pub(crate) fn extension_with_alias(
|
||||
self: &Arc<Self>,
|
||||
directory: &Path,
|
||||
alias: &Path,
|
||||
) -> io::Result<Arc<ExtensionFiles>> {
|
||||
let root = Root::open(directory)?;
|
||||
let alias = clean(&std::path::absolute(alias)?);
|
||||
let alias = if alias == root.path {
|
||||
None
|
||||
} else if std::fs::canonicalize(&alias)? == std::fs::canonicalize(&root.path)? {
|
||||
Some(alias)
|
||||
} else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"sandbox alias refers to a different directory",
|
||||
));
|
||||
};
|
||||
Ok(Arc::new(ExtensionFiles {
|
||||
root,
|
||||
legacy_alias: alias,
|
||||
registry: Arc::clone(self),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_native_replacement(
|
||||
&self,
|
||||
path: &str,
|
||||
bases: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
let target = clean(Path::new(path));
|
||||
let granted = self.allowed.read().expect("file grants lock");
|
||||
let temporary: Vec<_> = self
|
||||
.temporary
|
||||
.read()
|
||||
.expect("temporary file grants lock")
|
||||
.iter()
|
||||
.filter_map(Weak::upgrade)
|
||||
.collect();
|
||||
for base in bases
|
||||
.iter()
|
||||
.chain(granted.iter().chain(&temporary).map(|root| &root.path))
|
||||
{
|
||||
let base = clean(&std::path::absolute(base).map_err(|error| error.to_string())?);
|
||||
if let Ok(relative) = target.strip_prefix(&base) {
|
||||
let directory = match Dir::open_ambient_dir(&base, ambient_authority()) {
|
||||
Ok(directory) => directory,
|
||||
// Go accepts a future output path; validation must not create it.
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
return FilePath {
|
||||
root: Arc::new(Root {
|
||||
path: base.clone(),
|
||||
directory,
|
||||
}),
|
||||
absolute: target.clone(),
|
||||
relative: relative.into(),
|
||||
}
|
||||
.native_display()
|
||||
.map(|_| ());
|
||||
}
|
||||
}
|
||||
Err("replacement file path is outside allowed directories".into())
|
||||
}
|
||||
|
||||
fn lock(
|
||||
&self,
|
||||
path: &Path,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<OutputGuard, String> {
|
||||
let lock = {
|
||||
let mut outputs = self.outputs.lock().expect("file outputs lock");
|
||||
outputs.retain(|_, value| value.strong_count() > 0);
|
||||
let lock = outputs
|
||||
.get(path)
|
||||
.and_then(Weak::upgrade)
|
||||
.unwrap_or_default();
|
||||
outputs.insert(path.to_owned(), Arc::downgrade(&lock));
|
||||
lock
|
||||
};
|
||||
let mut owner = lock.owner.lock().expect("file owner lock");
|
||||
while let Some(id) = *owner {
|
||||
check()?;
|
||||
if id == thread::current().id() {
|
||||
return Err("file output is already in use by this callback".into());
|
||||
}
|
||||
owner = lock
|
||||
.idle
|
||||
.wait_timeout(owner, Duration::from_millis(10))
|
||||
.expect("file output wait")
|
||||
.0;
|
||||
}
|
||||
check()?;
|
||||
*owner = Some(thread::current().id());
|
||||
drop(owner);
|
||||
Ok(OutputGuard(lock))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExtensionFiles {
|
||||
root: Arc<Root>,
|
||||
legacy_alias: Option<PathBuf>,
|
||||
registry: Arc<FileRegistry>,
|
||||
}
|
||||
|
||||
impl ExtensionFiles {
|
||||
pub(crate) fn resolve_legacy(&self, path: &str) -> Result<FilePath, String> {
|
||||
let original = Path::new(path);
|
||||
let path = clean(original);
|
||||
if path.is_absolute() {
|
||||
for root in std::iter::once(&self.root.path).chain(self.legacy_alias.as_ref()) {
|
||||
if let Ok(relative) = path.strip_prefix(root) {
|
||||
if root != &self.root.path
|
||||
&& !std::fs::canonicalize(root).is_ok_and(|alias| {
|
||||
std::fs::canonicalize(&self.root.path)
|
||||
.is_ok_and(|canonical| alias == canonical)
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"file access denied: sandbox alias refers to a different directory"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut resolved =
|
||||
self.resolve(relative.to_str().ok_or("invalid UTF-8 path")?)?;
|
||||
// Keep native path spelling in cache keys and results while
|
||||
// opening through the original sandbox capability.
|
||||
resolved.absolute = original.to_owned();
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut resolved = self.resolve(path.to_str().ok_or("invalid UTF-8 path")?)?;
|
||||
if original.is_absolute() {
|
||||
resolved.absolute = original.to_owned();
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(&self, path: &str) -> Result<FilePath, String> {
|
||||
let input = clean(Path::new(path));
|
||||
let (root, absolute) = if input.is_absolute() {
|
||||
let root = self
|
||||
.registry
|
||||
.allowed
|
||||
.read()
|
||||
.expect("file grants lock")
|
||||
.iter()
|
||||
.find(|root| input.starts_with(&root.path))
|
||||
.cloned();
|
||||
let root = root.or_else(|| self.registry.temporary.read().expect("temporary file grants lock")
|
||||
.iter().filter_map(Weak::upgrade).find(|root| input.starts_with(&root.path)))
|
||||
.ok_or("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")?;
|
||||
(root, input)
|
||||
} else {
|
||||
let absolute = clean(&self.root.path.join(input));
|
||||
if !absolute.starts_with(&self.root.path) {
|
||||
return Err(format!(
|
||||
"file access denied: path '{path}' is outside sandbox"
|
||||
));
|
||||
}
|
||||
(Arc::clone(&self.root), absolute)
|
||||
};
|
||||
let relative = absolute
|
||||
.strip_prefix(&root.path)
|
||||
.expect("validated file root")
|
||||
.to_owned();
|
||||
Ok(FilePath {
|
||||
root,
|
||||
absolute,
|
||||
relative: if relative.as_os_str().is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
relative
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn lock(
|
||||
&self,
|
||||
path: &FilePath,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<OutputGuard, String> {
|
||||
// Go coordinates case-folded paths, including aliases on the default
|
||||
// case-insensitive mobile/macOS filesystems.
|
||||
self.registry
|
||||
.lock(Path::new(&path.display().to_lowercase()), check)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FilePath {
|
||||
root: Arc<Root>,
|
||||
pub absolute: PathBuf,
|
||||
relative: PathBuf,
|
||||
}
|
||||
|
||||
impl FilePath {
|
||||
/// Native FFmpeg opens a pathname rather than this capability descriptor.
|
||||
/// Reject existing symlinks and special files before handing it to native.
|
||||
/// The adapter must retain exclusive ownership of these paths through use.
|
||||
pub(crate) fn native_display(&self) -> Result<String, String> {
|
||||
let mut path = PathBuf::new();
|
||||
for component in self.relative.components() {
|
||||
path.push(component);
|
||||
match self.root.directory.symlink_metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
|
||||
return Err(
|
||||
"native media path must not contain symlinks or special files".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(self.display())
|
||||
}
|
||||
|
||||
pub(crate) fn open_parent(&self) -> io::Result<(Dir, std::ffi::OsString)> {
|
||||
self.mkdir_parent()?;
|
||||
let name = self.relative.file_name().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "invalid output filename")
|
||||
})?;
|
||||
Ok((
|
||||
self.root.directory.open_dir(self.parent())?,
|
||||
name.to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
self.absolute.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> io::Result<cap_std::fs::Metadata> {
|
||||
self.root.directory.metadata(&self.relative)
|
||||
}
|
||||
|
||||
pub(crate) fn entries(&self) -> io::Result<Vec<(String, bool)>> {
|
||||
let mut entries = self
|
||||
.root
|
||||
.directory
|
||||
.read_dir(&self.relative)?
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
Ok((
|
||||
entry.file_name().to_string_lossy().into_owned(),
|
||||
entry.file_type()?.is_dir(),
|
||||
))
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn mkdir_parent(&self) -> io::Result<()> {
|
||||
self.root.directory.create_dir_all(self.parent())
|
||||
}
|
||||
|
||||
pub(crate) fn require_parent(&self) -> Result<(), String> {
|
||||
self.root
|
||||
.directory
|
||||
.open_dir(self.parent())
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn parent(&self) -> &Path {
|
||||
self.relative
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."))
|
||||
}
|
||||
|
||||
pub fn open(&self, options: &mut OpenOptions) -> io::Result<File> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::OpenOptionsExt;
|
||||
// FIFOs/devices must not block a VM worker before cancellation can
|
||||
// run. Only regular files are accepted after opening the descriptor.
|
||||
options
|
||||
.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32)
|
||||
.mode(0o644);
|
||||
}
|
||||
let file = self.root.directory.open_with(&self.relative, options)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a regular file",
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Open a native read through its granted directory, rejecting symlinks at
|
||||
/// every component without separately walking the same path for validation.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn open_native_read(&self) -> io::Result<File> {
|
||||
use rustix::fd::{AsFd, OwnedFd};
|
||||
use rustix::fs::{AtFlags, FileType, Mode, OFlags, openat, statat};
|
||||
|
||||
let mut directory: Option<OwnedFd> = None;
|
||||
let mut components = self.relative.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
if !matches!(component, Component::Normal(_) | Component::CurDir) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid native path",
|
||||
));
|
||||
}
|
||||
let name = component.as_os_str();
|
||||
let parent = directory
|
||||
.as_ref()
|
||||
.map_or_else(|| self.root.directory.as_fd(), AsFd::as_fd);
|
||||
let last = components.peek().is_none();
|
||||
let mut flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
|
||||
if !last {
|
||||
flags |= OFlags::DIRECTORY;
|
||||
// Match capability traversal: a known child only needs search
|
||||
// permission on its parents, not permission to list them.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
flags |= OFlags::PATH;
|
||||
}
|
||||
}
|
||||
let fd = match openat(parent, name, flags, Mode::empty()) {
|
||||
Ok(fd) => fd,
|
||||
Err(error) => {
|
||||
// Preserve the native-path error for links and special
|
||||
// files even when a platform reports ENOTDIR or ENXIO.
|
||||
if error != rustix::io::Errno::NOENT
|
||||
&& let Ok(info) = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
|
||||
{
|
||||
let kind = FileType::from_raw_mode(info.st_mode);
|
||||
if !matches!(kind, FileType::RegularFile | FileType::Directory) {
|
||||
return Err(io::Error::other(
|
||||
"native media path must not contain symlinks or special files",
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if last {
|
||||
let file = std::fs::File::from(fd);
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.is_file() {
|
||||
return Ok(File::from_std(file));
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
if metadata.is_dir() {
|
||||
"not a regular file"
|
||||
} else {
|
||||
"native media path must not contain symlinks or special files"
|
||||
},
|
||||
));
|
||||
}
|
||||
directory = Some(fd);
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"empty native path",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn open_native_read(&self) -> io::Result<File> {
|
||||
self.native_display().map_err(io::Error::other)?;
|
||||
self.open(OpenOptions::new().read(true))
|
||||
}
|
||||
|
||||
pub fn remove(&self) -> io::Result<()> {
|
||||
let info = self.root.directory.symlink_metadata(&self.relative)?;
|
||||
if info.is_dir() {
|
||||
self.root.directory.remove_dir(&self.relative)
|
||||
} else {
|
||||
self.root.directory.remove_file(&self.relative)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_to(&self, target: &Self) -> io::Result<()> {
|
||||
self.root
|
||||
.directory
|
||||
.rename(&self.relative, &target.root.directory, &target.relative)
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> io::Result<StagedFile> {
|
||||
self.stage_with_parent(true, None).map(|(stage, _)| stage)
|
||||
}
|
||||
|
||||
pub(crate) fn stage_existing_parent(&self) -> io::Result<StagedFile> {
|
||||
self.stage_with_parent(false, None).map(|(stage, _)| stage)
|
||||
}
|
||||
|
||||
/// Link a completed private provider file into staging without copying its
|
||||
/// bytes. Verify the link against the caller's open descriptor before use.
|
||||
pub(crate) fn stage_from(&self, input: &Self, source: &File) -> io::Result<(StagedFile, bool)> {
|
||||
self.stage_with_parent(true, Some((input, source)))
|
||||
}
|
||||
|
||||
fn stage_with_parent(
|
||||
&self,
|
||||
create_parent: bool,
|
||||
source: Option<(&Self, &File)>,
|
||||
) -> io::Result<(StagedFile, bool)> {
|
||||
use aes_gcm::aead::{OsRng, rand_core::RngCore};
|
||||
if create_parent {
|
||||
self.mkdir_parent()?;
|
||||
}
|
||||
let parent = self.root.directory.open_dir(self.parent())?;
|
||||
let target = self
|
||||
.relative
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid output filename"))?
|
||||
.to_owned();
|
||||
for _ in 0..8 {
|
||||
let mut nonce = [0; 16];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let name = format!(
|
||||
".spotiflac-{}.partial",
|
||||
crate::binary::encode(&nonce, "hex").expect("hex encoding")
|
||||
);
|
||||
#[cfg(unix)]
|
||||
if let Some((input, source)) = source {
|
||||
use cap_std::fs::MetadataExt;
|
||||
match input
|
||||
.root
|
||||
.directory
|
||||
.hard_link(&input.relative, &parent, &name)
|
||||
{
|
||||
Ok(()) => {
|
||||
let linked = rustix::fs::openat(
|
||||
&parent,
|
||||
&name,
|
||||
rustix::fs::OFlags::RDONLY
|
||||
| rustix::fs::OFlags::NOFOLLOW
|
||||
| rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.map(|fd| File::from_std(std::fs::File::from(fd)))
|
||||
.map_err(io::Error::from);
|
||||
let linked = linked.and_then(|file| {
|
||||
let actual = file.metadata()?;
|
||||
let expected = source.metadata()?;
|
||||
if actual.is_file()
|
||||
&& actual.dev() == expected.dev()
|
||||
&& actual.ino() == expected.ino()
|
||||
{
|
||||
Ok(file)
|
||||
} else {
|
||||
Err(io::Error::other(
|
||||
"provider source changed before publication",
|
||||
))
|
||||
}
|
||||
});
|
||||
if let Ok(file) = linked {
|
||||
return Ok((
|
||||
StagedFile {
|
||||
parent,
|
||||
name,
|
||||
target,
|
||||
file,
|
||||
published: false,
|
||||
},
|
||||
true,
|
||||
));
|
||||
}
|
||||
parent.remove_file(&name)?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
// Cross-device and filesystems without hard links retain
|
||||
// the descriptor-based copy path below.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = source;
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::OpenOptionsExt;
|
||||
options.mode(0o644);
|
||||
}
|
||||
match parent.open_with(&name, &options) {
|
||||
Ok(file) => {
|
||||
return Ok((
|
||||
StagedFile {
|
||||
parent,
|
||||
name,
|
||||
target,
|
||||
file,
|
||||
published: false,
|
||||
},
|
||||
false,
|
||||
));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("unable to allocate staged output"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StagedFile {
|
||||
parent: Dir,
|
||||
name: String,
|
||||
target: std::ffi::OsString,
|
||||
pub file: File,
|
||||
published: bool,
|
||||
}
|
||||
|
||||
impl StagedFile {
|
||||
/// Album resolution must not replace a file created by another publisher
|
||||
/// after the planner checked the destination. Both names are in this parent.
|
||||
pub fn publish_new(mut self, check: &dyn Fn() -> Result<(), String>) -> io::Result<()> {
|
||||
self.file.sync_all()?;
|
||||
check().map_err(io::Error::other)?;
|
||||
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
|
||||
rustix::fs::renameat_with(
|
||||
&self.parent,
|
||||
&self.name,
|
||||
&self.parent,
|
||||
&self.target,
|
||||
rustix::fs::RenameFlags::NOREPLACE,
|
||||
)?;
|
||||
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
self.parent
|
||||
.hard_link(&self.name, &self.parent, &self.target)?;
|
||||
let _ = self.parent.remove_file(&self.name);
|
||||
}
|
||||
self.published = true;
|
||||
let _ = self
|
||||
.parent
|
||||
.try_clone()
|
||||
.and_then(|parent| parent.into_std_file().sync_all());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn publish(mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
|
||||
self.file.sync_all().map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
self.parent
|
||||
.rename(&self.name, &self.parent, &self.target)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.published = true;
|
||||
// Match native tag/download publication durability where the filesystem
|
||||
// supports directory fsync. Unsupported directory sync is best-effort.
|
||||
let _ = self
|
||||
.parent
|
||||
.try_clone()
|
||||
.and_then(|parent| parent.into_std_file().sync_all());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StagedFile {
|
||||
fn drop(&mut self) {
|
||||
if !self.published {
|
||||
let _ = self.parent.remove_file(&self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutputLock {
|
||||
owner: Mutex<Option<ThreadId>>,
|
||||
idle: Condvar,
|
||||
}
|
||||
|
||||
pub(crate) struct OutputGuard(Arc<OutputLock>);
|
||||
|
||||
impl Drop for OutputGuard {
|
||||
fn drop(&mut self) {
|
||||
*self.0.owner.lock().expect("file owner lock") = None;
|
||||
self.0.idle.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean(path: &Path) -> PathBuf {
|
||||
let mut result = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if result.file_name().is_some_and(|name| name != "..") {
|
||||
result.pop();
|
||||
} else if !result.has_root() {
|
||||
result.push("..");
|
||||
}
|
||||
}
|
||||
_ => result.push(component),
|
||||
}
|
||||
}
|
||||
if result.as_os_str().is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::{fs::symlink, net::UnixListener};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
|
||||
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let parent = root.path().join("album");
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
|
||||
let result = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read();
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(result.is_ok(), "execute-only parent: {result:?}");
|
||||
}
|
||||
let mut file = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap();
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
assert_eq!(data, b"original");
|
||||
assert!(file.write_all(b"changed").is_err());
|
||||
for (target, link) in [
|
||||
("album", "album-link"),
|
||||
("album/disc/song.wav", "song-link.wav"),
|
||||
("missing.wav", "dangling.wav"),
|
||||
] {
|
||||
symlink(target, root.path().join(link)).unwrap();
|
||||
}
|
||||
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("symlinks")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
files
|
||||
.resolve("missing.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("mkfifo")
|
||||
.arg(root.path().join("pipe.wav"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
|
||||
for path in ["pipe.wav", "socket.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("special files")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
|
||||
b"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let path = files.resolve("track.flac").unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"new audio").unwrap();
|
||||
assert!(!path.absolute.exists());
|
||||
let error = stage
|
||||
.publish_new(&|| {
|
||||
// A different publisher creates the destination after the
|
||||
// planner's existence check but before the publication syscall.
|
||||
std::fs::write(&path.absolute, b"other publisher").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
|
||||
|
||||
let cancelled = files.resolve("cancelled.flac").unwrap();
|
||||
let mut stage = cancelled.stage().unwrap();
|
||||
stage.file.write_all(b"cancelled audio").unwrap();
|
||||
assert_eq!(
|
||||
stage
|
||||
.publish_new(&|| Err("cancelled".into()))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"cancelled"
|
||||
);
|
||||
assert!(!cancelled.absolute.exists());
|
||||
|
||||
path.remove().unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"complete audio").unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
|
||||
use cap_std::fs::MetadataExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let source_metadata = source.metadata().unwrap();
|
||||
let stage_metadata = stage.file.metadata().unwrap();
|
||||
assert_eq!(source_metadata.dev(), stage_metadata.dev());
|
||||
assert_eq!(source_metadata.ino(), stage_metadata.ino());
|
||||
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
drop(source);
|
||||
source_path.remove().unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"source audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_becomes_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
symlink("source.old", &source_path.absolute).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
std::fs::write(&destination.absolute, b"existing audio").unwrap();
|
||||
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Ok(())).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"existing audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
|
||||
|
||||
std::fs::remove_file(&destination.absolute).unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
|
||||
assert_eq!(error.to_string(), "cancelled");
|
||||
assert!(!destination.absolute.exists());
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::{ExtensionFiles, clean};
|
||||
use cap_std::fs::{Metadata, OpenOptions};
|
||||
use spotiflac_core::isrc::{FileStamp, IndexFiles};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
impl IndexFiles for ExtensionFiles {
|
||||
fn list(
|
||||
&self,
|
||||
directory: &str,
|
||||
check: &(dyn Fn() -> Result<(), String> + Sync),
|
||||
) -> Result<Vec<FileStamp>, String> {
|
||||
let directory = self.resolve_legacy(directory)?;
|
||||
let mut pending = vec![(directory.relative.clone(), directory.absolute.clone())];
|
||||
let mut result = Vec::new();
|
||||
while let Some((relative, absolute)) = pending.pop() {
|
||||
check()?;
|
||||
let Ok(metadata) = directory.root.directory.symlink_metadata(&relative) else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_dir() {
|
||||
let path = absolute.to_str().ok_or("invalid UTF-8 path")?;
|
||||
result.push(stamp(path, &metadata));
|
||||
continue;
|
||||
}
|
||||
let Ok(opened) = directory.root.directory.open_dir(&relative) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(entries) = opened.entries() else {
|
||||
continue;
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
for entry in entries {
|
||||
check()?;
|
||||
if let Ok(entry) = entry {
|
||||
names.push(entry.file_name());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
pending.extend(
|
||||
names
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|name| (relative.join(&name), clean(&absolute.join(name)))),
|
||||
);
|
||||
}
|
||||
check()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
path: &str,
|
||||
check: &(dyn Fn() -> Result<(), String> + Sync),
|
||||
) -> Result<String, String> {
|
||||
check()?;
|
||||
let resolved = self.resolve_legacy(path)?;
|
||||
let Ok(mut file) = resolved.open(OpenOptions::new().read(true)) else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let format = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
spotiflac_core::isrc::read_isrc(&mut file, &format, check)
|
||||
}
|
||||
|
||||
fn stat(&self, path: &str) -> Result<Option<FileStamp>, String> {
|
||||
let path = self.resolve_legacy(path)?;
|
||||
Ok(path
|
||||
.metadata()
|
||||
.ok()
|
||||
.map(|metadata| stamp(&path.display(), &metadata)))
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(path: &str, metadata: &Metadata) -> FileStamp {
|
||||
let modified_ns = metadata
|
||||
.modified()
|
||||
.map(|time| match time.into_std().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_nanos() as i128,
|
||||
Err(error) => -(error.duration().as_nanos() as i128),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
FileStamp {
|
||||
path: path.into(),
|
||||
size: metadata.len(),
|
||||
modified_ns,
|
||||
directory: metadata.is_dir(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use crate::storage::{StorageError, StoreKind};
|
||||
use base64::Engine;
|
||||
use base64::alphabet;
|
||||
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use md5::Md5;
|
||||
use rquickjs::{Ctx, Function, Object};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<Object<'js>> {
|
||||
let host = Object::new(ctx.clone())?;
|
||||
crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
let item_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"downloadItemActive",
|
||||
Function::new(ctx.clone(), move || !item_control.item_id().is_empty())?,
|
||||
)?;
|
||||
let status_control = Arc::clone(&control);
|
||||
let downloads = Arc::clone(&services.downloads);
|
||||
host.set(
|
||||
"downloadStatus",
|
||||
Function::new(ctx.clone(), move |status: String| {
|
||||
let id = status_control.item_id();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = match status.trim().to_lowercase().as_str() {
|
||||
"preparing" => downloads.progress.preparing(&id, ""),
|
||||
"downloading" => downloads.progress.downloading(&id),
|
||||
"finalizing" => downloads.progress.finalizing(&id),
|
||||
_ => Ok(()),
|
||||
};
|
||||
})?,
|
||||
)?;
|
||||
let resolution_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"resolutionRemaining",
|
||||
Function::new(ctx.clone(), move || {
|
||||
resolution_control
|
||||
.resolution()
|
||||
.map_or(60_000, |budget| budget.remaining().as_millis() as u64)
|
||||
})?,
|
||||
)?;
|
||||
crate::crypto_host::register(ctx, &host, Arc::clone(&control))?;
|
||||
crate::file_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::ffmpeg_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::download_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::network_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::session_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
host.set("authEnabled", services.auth.is_some())?;
|
||||
if let Some(auth) = &services.auth {
|
||||
let auth = Arc::clone(auth);
|
||||
let control = Arc::clone(&control);
|
||||
host.set(
|
||||
"authCall",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |method: String, arguments: String, expires_is_float: bool| {
|
||||
let arguments = zeroize::Zeroizing::new(arguments);
|
||||
let result = serde_json::from_str::<Vec<serde_json::Value>>(&arguments)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|arguments| {
|
||||
auth.call(&method, &arguments, expires_is_float, || {
|
||||
control.check().map_err(|error| error.to_string())
|
||||
})
|
||||
});
|
||||
match result {
|
||||
Ok(value) => value,
|
||||
Err(error) if method == "generatePKCE" => {
|
||||
serde_json::json!({"error":error})
|
||||
}
|
||||
Err(error) => serde_json::json!({"success":false,"error":error}),
|
||||
}
|
||||
.to_string()
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
host.set("storageEnabled", services.storage.is_some())?;
|
||||
if let Some(store) = &services.storage {
|
||||
let reader = Arc::clone(store);
|
||||
host.set(
|
||||
"storageRead",
|
||||
Function::new(ctx.clone(), move |credentials: bool, key: String| {
|
||||
let kind = if credentials {
|
||||
StoreKind::Credentials
|
||||
} else {
|
||||
StoreKind::Storage
|
||||
};
|
||||
match reader.get(kind, &key) {
|
||||
Ok(Some(value)) => serde_json::json!({"found":true,"value":value}),
|
||||
Ok(None) => serde_json::json!({"found":false}),
|
||||
Err(error) => serde_json::json!({"error":error.to_string()}),
|
||||
}
|
||||
.to_string()
|
||||
})?,
|
||||
)?;
|
||||
let writer = Arc::clone(store);
|
||||
host.set(
|
||||
"storageWrite",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |credentials: bool, key: String, value: String| {
|
||||
let kind = if credentials {
|
||||
StoreKind::Credentials
|
||||
} else {
|
||||
StoreKind::Storage
|
||||
};
|
||||
let result = serde_json::from_str(&value)
|
||||
.map_err(StorageError::from)
|
||||
.and_then(|value| writer.set(kind, &key, value));
|
||||
storage_result(result)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
let remover = Arc::clone(store);
|
||||
host.set(
|
||||
"storageRemove",
|
||||
Function::new(ctx.clone(), move |credentials: bool, key: String| {
|
||||
let kind = if credentials {
|
||||
StoreKind::Credentials
|
||||
} else {
|
||||
StoreKind::Storage
|
||||
};
|
||||
storage_result(remover.remove(kind, &key))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
host.set(
|
||||
"encodeBytes",
|
||||
Function::new(ctx.clone(), |value: Vec<u8>| STANDARD.encode(value))?,
|
||||
)?;
|
||||
host.set(
|
||||
"base64Encode",
|
||||
Function::new(ctx.clone(), |value: String| STANDARD.encode(value))?,
|
||||
)?;
|
||||
host.set(
|
||||
"base64Decode",
|
||||
Function::new(ctx.clone(), |value: String, url_safe: bool| {
|
||||
// Go accepts nonzero padding bits and ignores CR/LF, but requires padding.
|
||||
let value = value.replace(['\r', '\n'], "");
|
||||
let config = GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true);
|
||||
let decoded = GeneralPurpose::new(&alphabet::STANDARD, config)
|
||||
.decode(&value)
|
||||
.or_else(|error| {
|
||||
if url_safe {
|
||||
GeneralPurpose::new(&alphabet::URL_SAFE, config).decode(&value)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
decode_go_utf8(&decoded)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"encode",
|
||||
Function::new(ctx.clone(), |value: String| value.into_bytes())?,
|
||||
)?;
|
||||
host.set(
|
||||
"decode",
|
||||
Function::new(ctx.clone(), |value: Vec<u8>| decode_go_utf8(&value))?,
|
||||
)?;
|
||||
host.set(
|
||||
"md5",
|
||||
Function::new(ctx.clone(), |value: String| {
|
||||
format!("{:x}", Md5::digest(value))
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"sha256",
|
||||
Function::new(ctx.clone(), |value: String| {
|
||||
format!("{:x}", Sha256::digest(value))
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"hmacSHA256",
|
||||
Function::new(ctx.clone(), |message: String, key: String| {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(message.as_bytes());
|
||||
format!("{:x}", mac.finalize().into_bytes())
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"hmacSHA256Base64",
|
||||
Function::new(ctx.clone(), |message: String, key: String| {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(message.as_bytes());
|
||||
STANDARD.encode(mac.finalize().into_bytes())
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"hmacSHA1",
|
||||
Function::new(ctx.clone(), |key: Vec<u8>, message: Vec<u8>| {
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(&key).expect("HMAC accepts any key length");
|
||||
mac.update(&message);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"quoteJSON",
|
||||
Function::new(ctx.clone(), |value: String| {
|
||||
// Go encoding/json escapes HTML and JS separators in strings.
|
||||
serde_json::to_string(&value)
|
||||
.expect("JSON string encoding")
|
||||
.replace('&', "\\u0026")
|
||||
.replace('<', "\\u003c")
|
||||
.replace('>', "\\u003e")
|
||||
.replace('\u{2028}', "\\u2028")
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"sortKeys",
|
||||
Function::new(ctx.clone(), |mut keys: Vec<String>| {
|
||||
// UTF-8/code-point ordering, rather than JS sort's UTF-16 ordering.
|
||||
keys.sort();
|
||||
keys
|
||||
})?,
|
||||
)?;
|
||||
let sleep_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"sleep",
|
||||
Function::new(ctx.clone(), move |milliseconds: u64| {
|
||||
sleep_control.sleep(Duration::from_millis(milliseconds.min(300_000)))
|
||||
})?,
|
||||
)?;
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
fn storage_result(result: Result<(), StorageError>) -> String {
|
||||
match result {
|
||||
Ok(()) => serde_json::json!({"success":true}),
|
||||
Err(error) => serde_json::json!({"success":false,"error":error.to_string()}),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Go's UTF-8 decoder consumes one byte for every invalid rune. Rust's lossy
|
||||
// conversion groups some invalid prefixes, which would change extension text.
|
||||
pub(crate) fn decode_go_utf8(mut bytes: &[u8]) -> String {
|
||||
let mut decoded = String::with_capacity(bytes.len());
|
||||
while !bytes.is_empty() {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(valid) => {
|
||||
decoded.push_str(valid);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let prefix = error.valid_up_to();
|
||||
decoded
|
||||
.push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix"));
|
||||
decoded.push('\u{fffd}');
|
||||
bytes = &bytes[prefix + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
decoded
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use rquickjs::{Ctx, Exception, Function, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
host.set("legacyBackend", services.legacy_backend)?;
|
||||
if !services.legacy_backend {
|
||||
return Ok(());
|
||||
}
|
||||
files::register(ctx, host, Arc::clone(&control), services)?;
|
||||
lyrics::register(ctx, host, Arc::clone(&control), services)?;
|
||||
host.set(
|
||||
"legacySanitize",
|
||||
Function::new(ctx.clone(), |value: String| {
|
||||
spotiflac_core::filename::sanitize_filename(&value)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"legacyFilename",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>, template: String, metadata: String| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let result = serde_json::from_str(&metadata)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|metadata| {
|
||||
spotiflac_core::filename::build_filename_checked(
|
||||
&template,
|
||||
&metadata,
|
||||
8 * 1024 * 1024,
|
||||
&check,
|
||||
)
|
||||
});
|
||||
result.map_err(|error| Exception::throw_message(&ctx, &error))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
host.set(
|
||||
"legacyLocalTime",
|
||||
Function::new(ctx.clone(), || {
|
||||
serde_json::to_string(&spotiflac_core::clock::local_time()).expect("local time JSON")
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
mod files;
|
||||
mod lyrics;
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use rquickjs::{Ctx, Function, Object};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
let files = services
|
||||
.legacy_files
|
||||
.clone()
|
||||
.or_else(|| services.files.clone());
|
||||
let quality_files = files.clone();
|
||||
let quality_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"legacyQuality",
|
||||
Function::new(ctx.clone(), move |path: String| {
|
||||
let check = || quality_control.check().map_err(|error| error.to_string());
|
||||
let result = (|| {
|
||||
check()?;
|
||||
let files = quality_files.as_ref().ok_or("file access unavailable")?;
|
||||
let path = files.resolve_legacy(&path)?;
|
||||
let mut file = path
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| format!("failed to open file: {error}"))?;
|
||||
spotiflac_core::media::probe_quality(&mut file, &check)
|
||||
})();
|
||||
match result {
|
||||
Ok(quality) => json!({
|
||||
"bitDepth":quality.bit_depth,"sampleRate":quality.sample_rate,
|
||||
"totalSamples":quality.total_samples,"duration":quality.duration,
|
||||
"codec":quality.codec
|
||||
}),
|
||||
Err(error) => json!({"error":error}),
|
||||
}
|
||||
.to_string()
|
||||
})?,
|
||||
)?;
|
||||
let cache = Arc::clone(&services.isrc);
|
||||
host.set(
|
||||
"legacyIsrc",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |add: bool, directory: String, isrc: String, path: String| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let (directory, isrc, path) = (directory.trim(), isrc.trim(), path.trim());
|
||||
let result = (|| {
|
||||
if directory.is_empty() || isrc.is_empty() || (add && path.is_empty()) {
|
||||
return Err(if add {
|
||||
"outputDir, isrc, and filePath are required"
|
||||
} else {
|
||||
"outputDir and isrc are required"
|
||||
}
|
||||
.to_owned());
|
||||
}
|
||||
check()?;
|
||||
let files = files.as_ref().ok_or("file access unavailable")?;
|
||||
let directory = files.resolve_legacy(directory)?.display();
|
||||
if add {
|
||||
let path = files.resolve_legacy(path)?.display();
|
||||
cache.add(&directory, isrc, &path, files.as_ref(), &check)?;
|
||||
Ok(json!({"success":true}))
|
||||
} else {
|
||||
let path = cache.check(&directory, isrc, files.as_ref(), &check)?;
|
||||
Ok(json!({"exists":!path.is_empty(),"filePath":path}))
|
||||
}
|
||||
})();
|
||||
result
|
||||
.unwrap_or_else(|error| json!({"error":error}))
|
||||
.to_string()
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use cap_std::fs::OpenOptions;
|
||||
use rquickjs::{Ctx, Function, Object};
|
||||
use serde_json::json;
|
||||
use spotiflac_core::lyrics::{file, lrc};
|
||||
use spotiflac_providers::lyrics::SearchRequest;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
let lyrics = services.lyrics.clone();
|
||||
let node = services.lyrics_node.clone();
|
||||
let files = services
|
||||
.legacy_files
|
||||
.clone()
|
||||
.or_else(|| services.files.clone());
|
||||
host.set(
|
||||
"legacyLyrics",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |spotify_id: String,
|
||||
track: String,
|
||||
artist: String,
|
||||
path: String,
|
||||
duration: f64,
|
||||
duration_text: Option<String>,
|
||||
number_conversion: bool| {
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let result: Result<String, String> = (|| {
|
||||
check()?;
|
||||
let (spotify_id, track, artist, path) =
|
||||
(spotify_id.trim(), track.trim(), artist.trim(), path.trim());
|
||||
if !path.is_empty() {
|
||||
let result = file::extract(
|
||||
path,
|
||||
&|path| {
|
||||
files
|
||||
.as_ref()
|
||||
.ok_or("file access unavailable")?
|
||||
.resolve_legacy(path)?
|
||||
.open(OpenOptions::new().read(true))
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
&check,
|
||||
);
|
||||
check()?;
|
||||
return Ok(result
|
||||
.ok()
|
||||
.filter(|text| lrc::has_usable_content(text))
|
||||
.unwrap_or_default());
|
||||
}
|
||||
let lyrics = lyrics.upgrade().ok_or("lyrics service unavailable")?;
|
||||
let response = lyrics
|
||||
.fetch(
|
||||
SearchRequest {
|
||||
spotify_id: spotify_id.into(),
|
||||
track: track.into(),
|
||||
artist: artist.into(),
|
||||
duration: duration_ms(
|
||||
duration,
|
||||
duration_text.as_deref(),
|
||||
number_conversion,
|
||||
) as f64
|
||||
/ 1000.0,
|
||||
caller: Some(node.clone()),
|
||||
..SearchRequest::default()
|
||||
},
|
||||
&check,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
check()?;
|
||||
Ok(if response.instrumental {
|
||||
"[instrumental:true]".into()
|
||||
} else {
|
||||
lrc::with_metadata(&response, track, artist)
|
||||
})
|
||||
})();
|
||||
match result {
|
||||
Ok(text) => json!({"lyrics":text}),
|
||||
Err(error) => json!({"error":error}),
|
||||
}
|
||||
.to_string()
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn duration_ms(number: f64, text: Option<&str>, number_conversion: bool) -> i64 {
|
||||
let Some(text) = text else {
|
||||
return number as i64;
|
||||
};
|
||||
// Goja's primitive unicodeString.ToInteger returns zero. Object coercion
|
||||
// first calls ToNumber, which trims ECMAScript whitespace before Go space.
|
||||
if !number_conversion && !text.is_ascii() {
|
||||
return 0;
|
||||
}
|
||||
let text = text.trim();
|
||||
let (digits, radix) = match text.get(..2) {
|
||||
Some("0x" | "0X") => (&text[2..], 16),
|
||||
Some("0b" | "0B") => (&text[2..], 2),
|
||||
Some("0o" | "0O") => (&text[2..], 8),
|
||||
_ => (text, 10),
|
||||
};
|
||||
match i64::from_str_radix(digits, radix) {
|
||||
Ok(value) => value,
|
||||
Err(_) if radix != 10 && number_conversion => 0,
|
||||
Err(error) if radix != 10 => match error.kind() {
|
||||
std::num::IntErrorKind::PosOverflow => i64::MAX,
|
||||
std::num::IntErrorKind::NegOverflow => i64::MIN,
|
||||
_ => 0,
|
||||
},
|
||||
_ => {
|
||||
// Preserve Go's decimal parsing and reject non-ECMAScript inf names.
|
||||
let parsed = if text.contains('_') {
|
||||
None
|
||||
} else {
|
||||
text.parse::<f64>().ok().filter(|value| {
|
||||
!value.is_infinite()
|
||||
|| matches!(text, "Infinity" | "+Infinity" | "-Infinity")
|
||||
|| text.contains(['e', 'E'])
|
||||
})
|
||||
};
|
||||
parsed.unwrap_or(0.0) as i64
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Extension JavaScript execution and host services, isolated from mobile UI code.
|
||||
|
||||
pub mod auth;
|
||||
pub mod backend;
|
||||
mod binary;
|
||||
pub mod crypto;
|
||||
mod crypto_host;
|
||||
mod download;
|
||||
mod download_host;
|
||||
pub mod environment;
|
||||
pub mod ffmpeg;
|
||||
mod ffmpeg_host;
|
||||
mod file_host;
|
||||
mod file_transform;
|
||||
pub mod files;
|
||||
mod host;
|
||||
mod legacy_host;
|
||||
pub mod logging;
|
||||
pub mod lyrics;
|
||||
pub mod manager;
|
||||
pub mod manifest;
|
||||
mod network_host;
|
||||
pub mod package;
|
||||
mod provider;
|
||||
mod redact;
|
||||
pub mod repository;
|
||||
mod resolution;
|
||||
mod runtime;
|
||||
mod session_host;
|
||||
pub mod signed_session;
|
||||
pub mod storage;
|
||||
pub mod transfer_policy;
|
||||
mod utility_host;
|
||||
|
||||
pub use runtime::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Process-style diagnostic buffer scoped to the native backend environment.
|
||||
|
||||
use crate::host::decode_go_utf8;
|
||||
use crate::redact::sanitize_bytes;
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const CAPACITY: usize = 500;
|
||||
const MAX_MESSAGE: usize = 4000;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub tag: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
entries: VecDeque<LogEntry>,
|
||||
next_index: i64,
|
||||
enabled: bool,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LogBuffer(Mutex<State>);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("log buffer closed")]
|
||||
pub struct LogClosed;
|
||||
|
||||
impl State {
|
||||
fn check(&self) -> Result<(), LogClosed> {
|
||||
if self.closed { Err(LogClosed) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), LogClosed> {
|
||||
let mut state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
state.enabled = enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> Result<bool, LogClosed> {
|
||||
let state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
Ok(state.enabled)
|
||||
}
|
||||
|
||||
pub fn add(&self, level: &str, tag: &str, message: &str) -> Result<(), LogClosed> {
|
||||
self.add_bytes(level, tag, message.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn add_bytes(&self, level: &str, tag: &str, message: Vec<u8>) -> Result<(), LogClosed> {
|
||||
let mut state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
if !state.enabled && level != "ERROR" && level != "FATAL" {
|
||||
return Ok(());
|
||||
}
|
||||
let message = decode_go_utf8(&truncate(sanitize_bytes(message)));
|
||||
let entry = LogEntry {
|
||||
timestamp: chrono::Local::now().format("%H:%M:%S%.3f").to_string(),
|
||||
level: level.into(),
|
||||
tag: tag.into(),
|
||||
message,
|
||||
};
|
||||
if state.entries.len() == CAPACITY {
|
||||
state.entries.pop_front();
|
||||
}
|
||||
use std::io::Write;
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"[{}] {}",
|
||||
entry.tag,
|
||||
entry.message
|
||||
);
|
||||
state.entries.push_back(entry);
|
||||
state.next_index = state.next_index.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn backend(&self, message: &str) -> Result<(), LogClosed> {
|
||||
self.backend_bytes(message.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn backend_bytes(&self, mut message: Vec<u8>) -> Result<(), LogClosed> {
|
||||
if message.last() == Some(&b'\n') {
|
||||
message.pop();
|
||||
}
|
||||
let mut tag = "Rust".to_owned();
|
||||
if message.first() == Some(&b'[')
|
||||
&& let Some(end) = message
|
||||
.iter()
|
||||
.position(|byte| *byte == b']')
|
||||
.filter(|end| *end > 1)
|
||||
{
|
||||
tag = decode_go_utf8(&message[1..end]);
|
||||
message = trim_bytes(&message[end + 1..]);
|
||||
}
|
||||
let lowercase = spotiflac_core::matching::lowercase(&decode_go_utf8(&message));
|
||||
let level = if lowercase.contains("error") || lowercase.contains("failed") {
|
||||
"ERROR"
|
||||
} else if lowercase.contains("warning") || lowercase.contains("warn") {
|
||||
"WARN"
|
||||
} else if lowercase.contains("success") || lowercase.contains("match found") {
|
||||
"INFO"
|
||||
} else if lowercase.contains("searching")
|
||||
|| lowercase.contains("trying")
|
||||
|| lowercase.contains("found")
|
||||
{
|
||||
"DEBUG"
|
||||
} else {
|
||||
"INFO"
|
||||
};
|
||||
self.add_bytes(level, &tag, message)
|
||||
}
|
||||
|
||||
pub(crate) fn extension(&self, id: &str, level: &str, arguments: Vec<String>, total: usize) {
|
||||
let mut message = Vec::new();
|
||||
for (index, argument) in arguments.into_iter().take(8).enumerate() {
|
||||
if index > 0 {
|
||||
message.push(b' ');
|
||||
}
|
||||
let bytes = argument.as_bytes();
|
||||
message.extend_from_slice(&bytes[..bytes.len().min(512)]);
|
||||
if bytes.len() > 512 {
|
||||
message.extend_from_slice(b"...[truncated]");
|
||||
}
|
||||
}
|
||||
if total > 8 {
|
||||
if !message.is_empty() {
|
||||
message.push(b' ');
|
||||
}
|
||||
message.extend_from_slice(format!("...[{} more args]", total - 8).as_bytes());
|
||||
}
|
||||
let formatted = truncate(sanitize_bytes(message));
|
||||
let mut line = if level.is_empty() {
|
||||
format!("[Extension:{id}] ")
|
||||
} else {
|
||||
format!("[Extension:{id}:{level}] ")
|
||||
}
|
||||
.into_bytes();
|
||||
line.extend(formatted);
|
||||
line.push(b'\n');
|
||||
let _ = self.backend_bytes(line);
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Result<String, LogClosed> {
|
||||
let state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
Ok(serde_json::to_string(&state.entries).expect("log entries JSON"))
|
||||
}
|
||||
|
||||
pub fn since(&self, index: i64) -> Result<String, LogClosed> {
|
||||
let state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
let earliest = state.next_index - state.entries.len() as i64;
|
||||
let skip = if index >= state.next_index {
|
||||
state.entries.len()
|
||||
} else {
|
||||
(index.max(earliest) - earliest) as usize
|
||||
};
|
||||
let logs: Vec<_> = state.entries.iter().skip(skip).collect();
|
||||
Ok(serde_json::json!({"logs":logs,"next_index":state.next_index}).to_string())
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), LogClosed> {
|
||||
let mut state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
state.entries.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn count(&self) -> Result<u64, LogClosed> {
|
||||
let state = self.0.lock().expect("log buffer lock");
|
||||
state.check()?;
|
||||
Ok(state.entries.len() as u64)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let mut state = self.0.lock().expect("log buffer lock");
|
||||
state.closed = true;
|
||||
state.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LogBuffer {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(mut message: Vec<u8>) -> Vec<u8> {
|
||||
if message.len() > MAX_MESSAGE {
|
||||
message.truncate(MAX_MESSAGE);
|
||||
if let Err(error) = std::str::from_utf8(&message) {
|
||||
message.truncate(error.valid_up_to());
|
||||
}
|
||||
message.extend_from_slice(b"...[truncated]");
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
fn trim_bytes(bytes: &[u8]) -> Vec<u8> {
|
||||
// Invalid bytes are non-whitespace in Go and are retained until JSON export.
|
||||
let decoded = decode_go_utf8(bytes);
|
||||
let left = decoded.len() - decoded.trim_start().len();
|
||||
let right = decoded.len() - decoded.trim_end().len();
|
||||
let end = bytes.len().saturating_sub(right);
|
||||
if left >= end {
|
||||
Vec::new()
|
||||
} else {
|
||||
bytes[left..end].to_vec()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Lyrics service adapter for installed providers and the shared built-in clients.
|
||||
|
||||
use crate::manager::ExtensionManager;
|
||||
use serde_json::json;
|
||||
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry};
|
||||
use spotiflac_core::lyrics::LyricsResponse;
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use spotiflac_providers::lyrics::{
|
||||
CallGraph, Check, LyricsError, LyricsFetcher, SearchRequest, builtin::BuiltinLyricsClient,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Weak, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The native owner retains the manager and service separately. A weak manager
|
||||
/// reference allows that owner to keep this adapter without a reference cycle.
|
||||
/// Lyrics flights register their wait on the manager VM before queueing work.
|
||||
/// A host call back into the service can then reject recursive dependencies.
|
||||
pub struct InstalledLyricsFetcher {
|
||||
manager: Weak<ExtensionManager>,
|
||||
closed: Arc<AtomicBool>,
|
||||
builtin: BuiltinLyricsClient,
|
||||
calls: CallGraph,
|
||||
}
|
||||
|
||||
impl InstalledLyricsFetcher {
|
||||
pub fn new(manager: &Arc<ExtensionManager>, builtin: BuiltinLyricsClient) -> Self {
|
||||
Self {
|
||||
manager: Arc::downgrade(manager),
|
||||
closed: manager.environment().closed_flag(),
|
||||
builtin,
|
||||
calls: manager.environment().lyrics_calls(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LyricsFetcher for InstalledLyricsFetcher {
|
||||
fn call_graph(&self) -> Option<CallGraph> {
|
||||
Some(self.calls.clone())
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), LyricsError> {
|
||||
if self.closed.load(Ordering::Acquire) || self.manager.strong_count() == 0 {
|
||||
Err(LyricsError::Cancelled("extension manager closed".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn extensions(&self) -> Vec<String> {
|
||||
self.manager
|
||||
.upgrade()
|
||||
.and_then(|manager| manager.provider_ids("lyrics_provider").ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|id| lowercase(id.trim()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&self,
|
||||
provider: &str,
|
||||
request: &SearchRequest,
|
||||
check: &Check<'_>,
|
||||
) -> Result<LyricsResponse, LyricsError> {
|
||||
check().map_err(LyricsError::Cancelled)?;
|
||||
let manager = self
|
||||
.manager
|
||||
.upgrade()
|
||||
.ok_or_else(|| LyricsError::Cancelled("extension manager closed".into()))?;
|
||||
let environment = manager.environment();
|
||||
let check = || {
|
||||
check()?;
|
||||
if environment.is_closed() {
|
||||
Err("extension manager closed".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check().map_err(LyricsError::Cancelled)?;
|
||||
let Some(id) = provider.strip_prefix("extension:") else {
|
||||
return self.builtin.fetch(provider, request, &check);
|
||||
};
|
||||
let id = manager
|
||||
.provider_ids("lyrics_provider")
|
||||
.map_err(|error| LyricsError::Other(error.to_string()))?
|
||||
.into_iter()
|
||||
.find(|candidate| lowercase(candidate.trim()) == id)
|
||||
.ok_or_else(|| LyricsError::Other(format!("lyrics provider unavailable: {id}")))?;
|
||||
let arguments = json!([request.track, request.artist, "", request.duration]).to_string();
|
||||
let _dependency = request
|
||||
.caller
|
||||
.as_ref()
|
||||
.map(|caller| caller.wait_for(&environment.lyrics_node(&id, false)))
|
||||
.transpose()
|
||||
.map_err(LyricsError::Recursive)?;
|
||||
let cancellation = CancellationRegistry::new(CancellationDomain::ExtensionRequest);
|
||||
let lease = Arc::new(
|
||||
cancellation
|
||||
.acquire("")
|
||||
.expect("new lyrics cancellation lease"),
|
||||
);
|
||||
let result = std::thread::scope(|scope| {
|
||||
let (done, completion) = mpsc::channel::<()>();
|
||||
let worker_lease = &lease;
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("installed-lyrics".into())
|
||||
.spawn_scoped(scope, move || {
|
||||
let _done = done;
|
||||
manager.provider_call(
|
||||
&id,
|
||||
"fetchLyrics",
|
||||
&arguments,
|
||||
Some(worker_lease.clone()),
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
.map_err(|error| LyricsError::Other(error.to_string()))?;
|
||||
let mut cancelled = None;
|
||||
loop {
|
||||
if let Err(message) = check() {
|
||||
cancelled = Some(message);
|
||||
lease.release();
|
||||
break;
|
||||
}
|
||||
if !matches!(
|
||||
completion.recv_timeout(Duration::from_millis(5)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let result = worker
|
||||
.join()
|
||||
.map_err(|_| LyricsError::Other("installed lyrics provider panicked".into()))?;
|
||||
if let Some(message) = cancelled {
|
||||
return Err(LyricsError::Cancelled(message));
|
||||
}
|
||||
check().map_err(LyricsError::Cancelled)?;
|
||||
let response = result.map_err(|error| LyricsError::Other(error.to_string()))?;
|
||||
serde_json::from_str(&response).map_err(|error| LyricsError::Other(error.to_string()))
|
||||
});
|
||||
lease.release();
|
||||
result
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,456 @@
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pool {
|
||||
pub active: Vec<Arc<ExtensionRuntime>>,
|
||||
idle: Option<Arc<ExtensionRuntime>>,
|
||||
generation: u64,
|
||||
settings: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProviderDownloadRequest {
|
||||
pub track_id: String,
|
||||
pub quality: String,
|
||||
pub output_path: String,
|
||||
pub item_id: String,
|
||||
pub prepared_context: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
struct Borrowed<'a> {
|
||||
manager: &'a ExtensionManager,
|
||||
entry: Arc<Installed>,
|
||||
runtime: Arc<ExtensionRuntime>,
|
||||
generation: u64,
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
impl Drop for Borrowed<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut pool = self.entry.downloads.lock().expect("download pool lock");
|
||||
// Environment settings access may create an absent store. Check manager
|
||||
// ownership first so a late borrower cannot resurrect uninstalled data.
|
||||
let reusable = self.healthy
|
||||
&& !self.runtime.is_closed()
|
||||
&& self.manager.current(&self.entry)
|
||||
&& self
|
||||
.entry
|
||||
.status
|
||||
.read()
|
||||
.expect("extension status lock")
|
||||
.enabled;
|
||||
let settings_match = reusable
|
||||
&& self
|
||||
.manager
|
||||
.environment
|
||||
.settings(&self.entry.manifest.name)
|
||||
.is_ok_and(|mut settings| {
|
||||
settings.retain(|key, _| !key.starts_with('_'));
|
||||
pool.settings.as_ref() == Some(&settings)
|
||||
});
|
||||
pool.active
|
||||
.retain(|runtime| !Arc::ptr_eq(runtime, &self.runtime));
|
||||
if reusable && pool.generation == self.generation && pool.idle.is_none() && settings_match {
|
||||
pool.idle = Some(Arc::clone(&self.runtime));
|
||||
} else if !self.runtime.is_closed() {
|
||||
self.manager.teardown_runtime(&self.runtime);
|
||||
} else {
|
||||
self.runtime.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
pub(crate) fn download_manifest(&self, id: &str) -> Result<ExtensionManifest, ManagerError> {
|
||||
let entry = self.get(id)?;
|
||||
let status = entry.status.read().expect("extension status lock");
|
||||
if !status.enabled || !status.error.is_empty() {
|
||||
return Err(error(format!("extension '{id}' is unavailable")));
|
||||
}
|
||||
Ok(entry.manifest.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_download(
|
||||
&self,
|
||||
id: &str,
|
||||
lease: Arc<RequestLease>,
|
||||
) -> Result<bool, ManagerError> {
|
||||
self.preflight_session(id, lease, true)
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_auth(
|
||||
&self,
|
||||
id: &str,
|
||||
lease: Arc<RequestLease>,
|
||||
) -> Result<bool, ManagerError> {
|
||||
self.preflight_session(id, lease, false)
|
||||
}
|
||||
|
||||
fn preflight_session(
|
||||
&self,
|
||||
id: &str,
|
||||
lease: Arc<RequestLease>,
|
||||
download_only: bool,
|
||||
) -> Result<bool, ManagerError> {
|
||||
let Ok(entry) = self.get(id) else {
|
||||
self.check()?;
|
||||
return Ok(false);
|
||||
};
|
||||
if !entry.status.read().expect("extension status lock").enabled
|
||||
|| (download_only && !entry.manifest.has_type("download_provider"))
|
||||
|| entry.manifest.signed_session.is_none()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let runtime = {
|
||||
let mut engine = self.download_engine(&entry, Some(&lease))?;
|
||||
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled
|
||||
{
|
||||
return Err(error("extension is disabled or no longer installed"));
|
||||
}
|
||||
self.ready_with_lease(&entry, &mut engine, true, Some(lease.clone()))?
|
||||
};
|
||||
runtime
|
||||
.preflight_signed_session(Some(lease), 30_000)
|
||||
.map_err(|failure| error(failure.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn download_engine<'a>(
|
||||
&self,
|
||||
entry: &'a Installed,
|
||||
lease: Option<&Arc<RequestLease>>,
|
||||
) -> Result<std::sync::MutexGuard<'a, Engine>, ManagerError> {
|
||||
loop {
|
||||
self.check()?;
|
||||
if let Some(lease) = lease {
|
||||
lease
|
||||
.check_active()
|
||||
.map_err(|failure| error(failure.to_string()))?;
|
||||
}
|
||||
match entry.engine.try_lock() {
|
||||
Ok(engine) => return Ok(engine),
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(_)) => {
|
||||
return Err(error("extension engine lock poisoned"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn invalidate_download_pool(&self, pool: &mut Pool) -> u64 {
|
||||
pool.generation = pool.generation.wrapping_add(1);
|
||||
pool.settings = None;
|
||||
if let Some(runtime) = pool.idle.take() {
|
||||
self.teardown_runtime(&runtime);
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory pressure releases idle heaps. Borrowed workers may finish but
|
||||
/// cannot repopulate the pool created before this request.
|
||||
pub fn release_idle_download_runtimes(&self) -> Result<u64, ManagerError> {
|
||||
self.check()?;
|
||||
let entries: Vec<_> = self
|
||||
.entries
|
||||
.lock()
|
||||
.expect("extension manager lock")
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut released = 0;
|
||||
for entry in entries {
|
||||
let mut pool = entry.downloads.lock().expect("download pool lock");
|
||||
released += self.invalidate_download_pool(&mut pool);
|
||||
}
|
||||
Ok(released)
|
||||
}
|
||||
|
||||
fn borrow_download(
|
||||
&self,
|
||||
entry: Arc<Installed>,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<Borrowed<'_>, ManagerError> {
|
||||
let mut engine = self.download_engine(&entry, lease.as_ref())?;
|
||||
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error("extension is disabled or no longer installed"));
|
||||
}
|
||||
let primary = self.ready_with_lease(&entry, &mut engine, true, lease)?;
|
||||
let mut settings = self
|
||||
.environment
|
||||
.settings(&entry.manifest.name)
|
||||
.map_err(error_from_environment)?;
|
||||
settings.retain(|key, _| !key.starts_with('_'));
|
||||
let mut pool = entry.downloads.lock().expect("download pool lock");
|
||||
if pool.settings.as_ref() != Some(&settings) {
|
||||
self.invalidate_download_pool(&mut pool);
|
||||
pool.settings = Some(settings.clone());
|
||||
}
|
||||
let idle = pool.idle.take();
|
||||
let generation = pool.generation;
|
||||
// Initialization runs extension code. Pressure may invalidate this
|
||||
// borrow while it initializes, without cancelling the operation.
|
||||
drop(pool);
|
||||
let runtime = match idle {
|
||||
Some(runtime) if !runtime.is_closed() => runtime,
|
||||
_ => {
|
||||
let runtime = self
|
||||
.environment
|
||||
.load_isolated(
|
||||
&entry.manifest_json,
|
||||
engine.source.as_ref().expect("initialized source"),
|
||||
self.limits.clone(),
|
||||
&primary,
|
||||
)
|
||||
.map_err(error_from_environment)?;
|
||||
if !settings.is_empty()
|
||||
&& let Err(error) =
|
||||
self.lifecycle(&runtime, "initialize", &json!([settings]).to_string())
|
||||
{
|
||||
self.teardown_runtime(&runtime);
|
||||
return Err(error);
|
||||
}
|
||||
runtime
|
||||
}
|
||||
};
|
||||
entry
|
||||
.downloads
|
||||
.lock()
|
||||
.expect("download pool lock")
|
||||
.active
|
||||
.push(Arc::clone(&runtime));
|
||||
drop(engine);
|
||||
Ok(Borrowed {
|
||||
manager: self,
|
||||
entry,
|
||||
runtime,
|
||||
generation,
|
||||
healthy: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute the installed provider's download contract in an isolated worker.
|
||||
/// File placement, post-processing and finalization remain caller-owned.
|
||||
pub fn download(
|
||||
&self,
|
||||
id: &str,
|
||||
request: ProviderDownloadRequest,
|
||||
resolution_timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.download_with_lease(id, request, resolution_timeout_ms, None)
|
||||
}
|
||||
|
||||
pub(crate) fn download_with_lease(
|
||||
&self,
|
||||
id: &str,
|
||||
request: ProviderDownloadRequest,
|
||||
resolution_timeout_ms: u64,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, ManagerError> {
|
||||
let entry = self.get(id)?;
|
||||
if !entry.manifest.has_type("download_provider") {
|
||||
return Err(error(format!(
|
||||
"extension '{id}' is not a download provider"
|
||||
)));
|
||||
}
|
||||
if !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error(format!("extension '{id}' is disabled")));
|
||||
}
|
||||
let mut borrowed = match self.borrow_download(entry, lease.clone()) {
|
||||
Ok(borrowed) => borrowed,
|
||||
Err(error) => return Ok(failure("init_error", &error.to_string())),
|
||||
};
|
||||
let allowance = if resolution_timeout_ms == 0 {
|
||||
60_000
|
||||
} else {
|
||||
resolution_timeout_ms.min(300_000)
|
||||
};
|
||||
let mut options = json!({"resolutionTimeoutMs":allowance});
|
||||
if let Some(context) = request
|
||||
.prepared_context
|
||||
.filter(|context| !context.is_empty())
|
||||
{
|
||||
options["preparedContext"] = context.into();
|
||||
}
|
||||
// This marker belongs to one VM and one invocation, never to a previous
|
||||
// download that returned its worker to the idle pool.
|
||||
borrowed.runtime.take_verification_url();
|
||||
let response = borrowed.runtime.call_download_provider(
|
||||
&json!([
|
||||
request.track_id,
|
||||
request.quality,
|
||||
request.output_path,
|
||||
options
|
||||
])
|
||||
.to_string(),
|
||||
&request.item_id,
|
||||
allowance,
|
||||
lease,
|
||||
);
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err((ExtensionError::Cancelled(cause), _)) => return Err(error(cause.to_string())),
|
||||
Err((ExtensionError::Timeout, resolution)) => {
|
||||
return Ok(failure(
|
||||
"timeout",
|
||||
if resolution {
|
||||
"stream resolution timeout: extension took too long to resolve an audio stream"
|
||||
} else {
|
||||
"download timeout: extension took too long to complete"
|
||||
},
|
||||
));
|
||||
}
|
||||
Err((error, _)) => return Ok(failure("script_error", &error.to_string())),
|
||||
};
|
||||
let mut response: Value = serde_json::from_str(&response)
|
||||
.map_err(|error| cause("invalid download result", error))?;
|
||||
if let Some(error) = response.get("parseError").and_then(Value::as_str) {
|
||||
return Ok(failure("script_error", error));
|
||||
}
|
||||
borrowed.healthy = true;
|
||||
let mut value = response["value"].take();
|
||||
if value.is_null() {
|
||||
return Ok(failure("not_implemented", "download returned null"));
|
||||
}
|
||||
normalize_decryption(&mut value);
|
||||
if value["success"] != true
|
||||
&& !borrowed.runtime.take_verification_url().is_empty()
|
||||
&& !value["error_type"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.eq_ignore_ascii_case("verification_required")
|
||||
{
|
||||
value["error_type"] = "verification_required".into();
|
||||
if value["error_message"].as_str().unwrap_or("").is_empty() {
|
||||
value["error_message"] = "Verification required".into();
|
||||
}
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(kind: &str, message: &str) -> String {
|
||||
json!({"success":false,"error_type":kind,"error_message":message}).to_string()
|
||||
}
|
||||
|
||||
fn normalize_decryption(value: &mut Value) {
|
||||
const MOV: &str = "ffmpeg.mov_key";
|
||||
let legacy = value["decryption_key"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_owned();
|
||||
let mut info = value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("decryption")
|
||||
.and_then(|info| info.as_object().cloned());
|
||||
if info.is_none() && !legacy.is_empty() {
|
||||
info = Some(Map::new());
|
||||
}
|
||||
if let Some(info) = &mut info {
|
||||
for key in ["strategy", "key", "iv", "input_format", "output_extension"] {
|
||||
if let Some(value) = info.get_mut(key) {
|
||||
*value = value.as_str().unwrap_or("").trim().into();
|
||||
}
|
||||
}
|
||||
let strategy = info.get("strategy").and_then(Value::as_str).unwrap_or("");
|
||||
let strategy = match spotiflac_core::matching::lowercase(strategy).as_str() {
|
||||
""
|
||||
| MOV
|
||||
| "ffmpeg_mov_key"
|
||||
| "mov_decryption_key"
|
||||
| "mp4_decryption_key"
|
||||
| "ffmpeg.mp4_decryption_key" => MOV.to_owned(),
|
||||
_ => strategy.to_owned(),
|
||||
};
|
||||
info.insert("strategy".into(), strategy.clone().into());
|
||||
if info
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
&& !legacy.is_empty()
|
||||
{
|
||||
info.insert("key".into(), legacy.clone().into());
|
||||
}
|
||||
if strategy == MOV
|
||||
&& info
|
||||
.get("input_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
info.insert("input_format".into(), "mov".into());
|
||||
}
|
||||
info.retain(|_, value| value != "");
|
||||
}
|
||||
if info.as_ref().is_some_and(|info| {
|
||||
info.get("strategy").and_then(Value::as_str) == Some(MOV)
|
||||
&& info
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
}) {
|
||||
info = None;
|
||||
}
|
||||
let key = info
|
||||
.as_ref()
|
||||
.filter(|info| info.get("strategy").and_then(Value::as_str) == Some(MOV))
|
||||
.and_then(|info| info.get("key"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&legacy)
|
||||
.to_owned();
|
||||
let value = value.as_object_mut().unwrap();
|
||||
value.remove("decryption_key");
|
||||
if !key.is_empty() {
|
||||
value.insert("decryption_key".into(), key.into());
|
||||
}
|
||||
if let Some(info) = info {
|
||||
value.insert("decryption".into(), info.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn returning_a_borrow_after_uninstall_cannot_recreate_extension_data() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let sources = directory.path().join("sources");
|
||||
let data = directory.path().join("data");
|
||||
let manager = ExtensionManager::new(
|
||||
&sources,
|
||||
&data,
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = sources.join("example.pool");
|
||||
fs::create_dir(&source).unwrap();
|
||||
fs::write(source.join("manifest.json"), json!({"name":"example.pool","version":"1","description":"Generic pool ownership test","type":["download_provider"],"permissions":{"storage":true}}).to_string()).unwrap();
|
||||
fs::write(
|
||||
source.join("index.js"),
|
||||
"registerExtension({download(){return {success:true}}});",
|
||||
)
|
||||
.unwrap();
|
||||
manager.load_all().unwrap();
|
||||
manager.set_enabled("example.pool", true).unwrap();
|
||||
let mut borrowed = manager
|
||||
.borrow_download(manager.get("example.pool").unwrap(), None)
|
||||
.unwrap();
|
||||
borrowed.healthy = true;
|
||||
manager.remove("example.pool").unwrap();
|
||||
assert!(!data.join("example.pool").exists());
|
||||
drop(borrowed);
|
||||
assert!(!data.join("example.pool").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
use spotiflac_core::cancellation::RequestLease;
|
||||
use spotiflac_core::matching::{lowercase, uppercase};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProviderAvailabilityRequest {
|
||||
pub isrc: String,
|
||||
pub track_name: String,
|
||||
pub artist_name: String,
|
||||
pub spotify_id: String,
|
||||
pub deezer_id: String,
|
||||
pub tidal_id: String,
|
||||
pub qobuz_id: String,
|
||||
pub duration_ms: isize,
|
||||
pub item_id: String,
|
||||
pub track: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
fn item_lease(&self, id: &str) -> Result<Option<Arc<RequestLease>>, ManagerError> {
|
||||
if id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.environment
|
||||
.download_state()
|
||||
.cancellation
|
||||
.acquire(id)
|
||||
.map(|lease| Some(Arc::new(lease)))
|
||||
.map_err(|failure| error(failure.to_string()))
|
||||
}
|
||||
|
||||
pub fn check_availability(
|
||||
&self,
|
||||
id: &str,
|
||||
request: ProviderAvailabilityRequest,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
let lease = self.item_lease(&request.item_id)?;
|
||||
self.check_availability_with_lease(id, request, timeout_ms, lease)
|
||||
}
|
||||
|
||||
pub(crate) fn check_availability_with_lease(
|
||||
&self,
|
||||
id: &str,
|
||||
request: ProviderAvailabilityRequest,
|
||||
timeout_ms: u64,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, ManagerError> {
|
||||
let mut options = json!({"spotify_id":request.spotify_id,"deezer_id":request.deezer_id,
|
||||
"tidal_id":request.tidal_id,"qobuz_id":request.qobuz_id,"duration_ms":request.duration_ms});
|
||||
if let Some(track) = request.track.filter(|track| !track.is_empty()) {
|
||||
options["track"] = track.into();
|
||||
}
|
||||
self.provider_operation(
|
||||
id,
|
||||
"checkAvailability",
|
||||
&json!([
|
||||
request.isrc,
|
||||
request.track_name,
|
||||
request.artist_name,
|
||||
options
|
||||
])
|
||||
.to_string(),
|
||||
lease,
|
||||
timeout_ms,
|
||||
&request.item_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort enrichment preserves the original provider attribution.
|
||||
/// Cancellation remains an error, allowing the download worker to stop.
|
||||
pub fn enrich_track(
|
||||
&self,
|
||||
id: &str,
|
||||
track_json: &str,
|
||||
item_id: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.enrich_track_with_lease(
|
||||
id,
|
||||
track_json,
|
||||
item_id,
|
||||
timeout_ms,
|
||||
self.item_lease(item_id)?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn enrich_track_export(
|
||||
&self,
|
||||
id: &str,
|
||||
track_json: &str,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.check()?;
|
||||
let Ok(entry) = self.get(id) else {
|
||||
return Ok(track_json.into());
|
||||
};
|
||||
if !entry.manifest.has_type("metadata_provider") {
|
||||
return Ok(track_json.into());
|
||||
}
|
||||
// This export unmarshals into a value struct, unlike the nullable
|
||||
// provider argument used by the internal download API.
|
||||
let input = if track_json.trim() == "null" {
|
||||
"{}"
|
||||
} else {
|
||||
track_json
|
||||
};
|
||||
self.enrich_track(id, input, "", 30_000).map_err(|failure| {
|
||||
if let Some(message) = failure.0.strip_prefix("invalid track: ") {
|
||||
error(format!("failed to parse track: {message}"))
|
||||
} else {
|
||||
failure
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn enrich_track_with_lease(
|
||||
&self,
|
||||
id: &str,
|
||||
track_json: &str,
|
||||
item_id: &str,
|
||||
timeout_ms: u64,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, ManagerError> {
|
||||
let (original, input) = track_input(track_json)?;
|
||||
let entry = self.get(id)?;
|
||||
if !entry.manifest.has_type("metadata_provider")
|
||||
|| !entry.status.read().expect("extension status lock").enabled
|
||||
{
|
||||
return Ok(original.to_string());
|
||||
}
|
||||
match self.provider_operation(
|
||||
id,
|
||||
"enrichTrack",
|
||||
&json!([input]).to_string(),
|
||||
lease,
|
||||
timeout_ms,
|
||||
item_id,
|
||||
) {
|
||||
Ok(response) => {
|
||||
let mut track: Value = serde_json::from_str(&response)
|
||||
.map_err(|failure| cause("invalid enriched track", failure))?;
|
||||
track["provider_id"] = original.get("provider_id").cloned().unwrap_or(json!(""));
|
||||
Ok(track.to_string())
|
||||
}
|
||||
Err(failure) if failure.0 == "download cancelled" => Err(failure),
|
||||
Err(failure) => {
|
||||
let _ = self.environment.log_buffer().backend(&format!(
|
||||
"[Extension] EnrichTrack error for {id}: {failure}"
|
||||
));
|
||||
Ok(original.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_metadata_provider(
|
||||
&self,
|
||||
id: &str,
|
||||
query: &str,
|
||||
limit: isize,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(error("metadata provider ID is required"));
|
||||
}
|
||||
let entry = self.get(id)?;
|
||||
if !entry.manifest.has_type("metadata_provider") {
|
||||
return Err(error(format!(
|
||||
"extension '{id}' is not a metadata provider"
|
||||
)));
|
||||
}
|
||||
{
|
||||
let status = entry.status.read().expect("extension status lock");
|
||||
if !status.enabled {
|
||||
return Err(error(format!("extension '{id}' is disabled")));
|
||||
}
|
||||
if !status.error.is_empty() {
|
||||
return Err(error(format!(
|
||||
"extension '{id}' is unavailable: {}",
|
||||
status.error
|
||||
)));
|
||||
}
|
||||
}
|
||||
let limit = search_limit(limit);
|
||||
let response = self.provider_call(
|
||||
id,
|
||||
"searchTracks",
|
||||
&json!([query, limit]).to_string(),
|
||||
None,
|
||||
timeout_ms,
|
||||
)?;
|
||||
let mut response: Value =
|
||||
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
|
||||
let mut tracks = response["tracks"].take();
|
||||
if let Some(tracks) = tracks.as_array_mut() {
|
||||
tracks.truncate(limit);
|
||||
}
|
||||
Ok(tracks.to_string())
|
||||
}
|
||||
|
||||
pub fn search_metadata_providers(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: isize,
|
||||
include_extensions: bool,
|
||||
item_id: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.search_metadata_providers_with_lease(
|
||||
query,
|
||||
limit,
|
||||
include_extensions,
|
||||
item_id,
|
||||
timeout_ms,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn search_metadata_providers_with_lease(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: isize,
|
||||
include_extensions: bool,
|
||||
item_id: &str,
|
||||
timeout_ms: u64,
|
||||
request_lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.check()?;
|
||||
let mut ordered = self
|
||||
.priorities
|
||||
.lock()
|
||||
.expect("provider priority lock")
|
||||
.metadata
|
||||
.clone();
|
||||
let providers: BTreeSet<_> = if include_extensions {
|
||||
self.provider_ids("metadata_provider")?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
let prioritized: BTreeSet<_> = ordered.iter().cloned().collect();
|
||||
ordered.extend(providers.difference(&prioritized).cloned());
|
||||
let limit = search_limit(limit);
|
||||
let mut tracks = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut verification = None;
|
||||
// Keep the same cancellation acquisition alive between providers.
|
||||
let lease = if request_lease.is_some() {
|
||||
request_lease
|
||||
} else if ordered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
self.item_lease(item_id)?
|
||||
};
|
||||
for id in ordered {
|
||||
if let Some(lease) = &lease {
|
||||
lease.check_active().map_err(|e| error(e.to_string()))?;
|
||||
}
|
||||
if !providers.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let response = self.provider_operation(
|
||||
&id,
|
||||
"searchTracks",
|
||||
&json!([query, limit]).to_string(),
|
||||
lease.clone(),
|
||||
timeout_ms,
|
||||
item_id,
|
||||
);
|
||||
if let Some(lease) = &lease {
|
||||
lease.check_active().map_err(|e| error(e.to_string()))?;
|
||||
}
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(failure) if failure.0 == "download cancelled" => return Err(failure),
|
||||
Err(failure) => {
|
||||
let _ = self.environment.log_buffer().backend(&format!(
|
||||
"[MetadataSearch] Search error from {id}: {failure}"
|
||||
));
|
||||
if verification.is_none() && requires_verification(&failure.0) {
|
||||
verification = Some(error(format!(
|
||||
"verification_required: extension '{id}' needs verification: {failure}"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let response: Value =
|
||||
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
|
||||
if let Some(found) = response["tracks"].as_array() {
|
||||
for track in found {
|
||||
if seen.insert(dedup_key(track)) {
|
||||
tracks.push(track.clone());
|
||||
if tracks.len() >= limit {
|
||||
return Ok(json!(tracks).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tracks.is_empty()
|
||||
&& let Some(failure) = verification
|
||||
{
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(json!(tracks).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn search_limit(limit: isize) -> usize {
|
||||
if limit <= 0 { 20 } else { limit as usize }
|
||||
}
|
||||
|
||||
fn requires_verification(message: &str) -> bool {
|
||||
let message = lowercase(message);
|
||||
if ["isp blocking", "try using vpn", "change dns", "cancel"]
|
||||
.iter()
|
||||
.any(|part| message.contains(part))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
[
|
||||
"verification_required",
|
||||
"session is not authenticated",
|
||||
"signed session is not authenticated",
|
||||
"signed session expired",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| message.contains(part))
|
||||
}
|
||||
|
||||
fn dedup_key(track: &Value) -> String {
|
||||
let field = |name: &str| track[name].as_str().unwrap_or("").trim();
|
||||
if !field("isrc").is_empty() {
|
||||
format!("isrc:{}", uppercase(field("isrc")))
|
||||
} else if !field("spotify_id").is_empty() {
|
||||
format!("spotify:{}", field("spotify_id"))
|
||||
} else if !field("provider_id").is_empty() && !field("id").is_empty() {
|
||||
format!("{}:{}", field("provider_id"), field("id"))
|
||||
} else {
|
||||
format!("{}|{}", field("name"), field("artists"))
|
||||
}
|
||||
}
|
||||
|
||||
// Native inputs use the canonical Go metadata JSON schema, not the aliases
|
||||
// accepted when reading extension JavaScript results. Every input field is
|
||||
// present, including zero values omitted from serialized metadata results.
|
||||
fn track_input(text: &str) -> Result<(Value, Value), ManagerError> {
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| cause("invalid track", e))?;
|
||||
if value.is_null() {
|
||||
return Ok((Value::Null, json!({})));
|
||||
}
|
||||
let Some(value) = value.as_object() else {
|
||||
return Err(error("invalid track: expected object or null"));
|
||||
};
|
||||
let mut input = Map::new();
|
||||
let mut original = Map::new();
|
||||
for key in [
|
||||
"id",
|
||||
"name",
|
||||
"artists",
|
||||
"album_name",
|
||||
"album_artist",
|
||||
"album_id",
|
||||
"album_url",
|
||||
"artist_id",
|
||||
"artist_url",
|
||||
"external_urls",
|
||||
"cover_url",
|
||||
"preview_url",
|
||||
"images",
|
||||
"release_date",
|
||||
"isrc",
|
||||
"provider_id",
|
||||
"item_type",
|
||||
"album_type",
|
||||
"tidal_id",
|
||||
"qobuz_id",
|
||||
"deezer_id",
|
||||
"spotify_id",
|
||||
"label",
|
||||
"copyright",
|
||||
"genre",
|
||||
"composer",
|
||||
"comment",
|
||||
"audio_quality",
|
||||
"audio_modes",
|
||||
"upc",
|
||||
] {
|
||||
let text = match value.get(key).filter(|v| !v.is_null()) {
|
||||
None => "",
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.ok_or_else(|| error(format!("invalid track: {key} must be a string")))?,
|
||||
};
|
||||
input.insert(key.into(), json!(text));
|
||||
if !text.is_empty() || ["id", "name", "artists", "album_name", "provider_id"].contains(&key)
|
||||
{
|
||||
original.insert(key.into(), json!(text));
|
||||
}
|
||||
}
|
||||
for key in [
|
||||
"duration_ms",
|
||||
"track_number",
|
||||
"total_tracks",
|
||||
"disc_number",
|
||||
"total_discs",
|
||||
] {
|
||||
let integer = match value.get(key).filter(|v| !v.is_null()) {
|
||||
None => 0,
|
||||
Some(value) => value
|
||||
.as_i64()
|
||||
.and_then(|value| isize::try_from(value).ok())
|
||||
.ok_or_else(|| error(format!("invalid track: {key} must be a native integer")))?,
|
||||
};
|
||||
input.insert(key.into(), json!(integer));
|
||||
if integer != 0 || key == "duration_ms" {
|
||||
original.insert(key.into(), json!(integer));
|
||||
}
|
||||
}
|
||||
let explicit = match value.get("explicit").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| error("invalid track: explicit must be a boolean"))?,
|
||||
};
|
||||
input.insert("explicit".into(), json!(explicit));
|
||||
if explicit {
|
||||
original.insert("explicit".into(), json!(true));
|
||||
}
|
||||
let links = value.get("external_links").cloned().unwrap_or(Value::Null);
|
||||
if !links.is_null()
|
||||
&& !links
|
||||
.as_object()
|
||||
.is_some_and(|links| links.values().all(Value::is_string))
|
||||
{
|
||||
return Err(error("invalid track: external_links must be a string map"));
|
||||
}
|
||||
if links.as_object().is_some_and(|links| !links.is_empty()) {
|
||||
original.insert("external_links".into(), links.clone());
|
||||
}
|
||||
// Go's argument adapter copies a typed nil map to an empty JS object.
|
||||
input.insert(
|
||||
"external_links".into(),
|
||||
if links.is_null() { json!({}) } else { links },
|
||||
);
|
||||
Ok((original.into(), input.into()))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use super::*;
|
||||
use crate::files::clean;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spotiflac_core::matching::lowercase;
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PostProcessInput {
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub item_id: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub uri: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub mime_type: String,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub size: i64,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub is_saf: bool,
|
||||
}
|
||||
|
||||
fn is_zero(value: &i64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!value
|
||||
}
|
||||
fn failed(message: impl std::fmt::Display) -> Value {
|
||||
json!({"success":false,"error":message.to_string()})
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
pub fn post_process(
|
||||
&self,
|
||||
id: &str,
|
||||
input: PostProcessInput,
|
||||
metadata: Map<String, Value>,
|
||||
hook: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
let lease = self.post_process_lease(&input)?;
|
||||
self.post_process_hook(id, &input, &metadata, hook, timeout_ms, lease)
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn post_process_lease(
|
||||
&self,
|
||||
input: &PostProcessInput,
|
||||
) -> Result<Option<Arc<RequestLease>>, ManagerError> {
|
||||
if input.item_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let lease = self
|
||||
.environment
|
||||
.download_state()
|
||||
.acquire(&input.item_id)
|
||||
.map_err(|failure| error(failure.to_string()))?;
|
||||
lease
|
||||
.check_active()
|
||||
.map_err(|failure| error(failure.to_string()))?;
|
||||
Ok(Some(Arc::new(lease)))
|
||||
}
|
||||
|
||||
fn post_process_hook(
|
||||
&self,
|
||||
id: &str,
|
||||
input: &PostProcessInput,
|
||||
metadata: &Map<String, Value>,
|
||||
hook: &str,
|
||||
timeout_ms: u64,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<Value, ManagerError> {
|
||||
let entry = self.get(id)?;
|
||||
if !entry
|
||||
.manifest
|
||||
.post_processing
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.enabled)
|
||||
{
|
||||
return Err(error(format!(
|
||||
"extension '{id}' does not support post-processing"
|
||||
)));
|
||||
}
|
||||
if !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error(format!("extension '{id}' is disabled")));
|
||||
}
|
||||
let mut engine = self.download_engine(&entry, lease.as_ref())?;
|
||||
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
|
||||
return Ok(failed("extension is disabled or no longer installed"));
|
||||
}
|
||||
let runtime = match self.ready_with_lease(&entry, &mut engine, true, lease.clone()) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
if lease
|
||||
.as_ref()
|
||||
.is_some_and(|lease| lease.check_active().is_err())
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
self.failed(&entry, &error);
|
||||
return Ok(failed(error));
|
||||
}
|
||||
};
|
||||
let result = match runtime.call_post_process(
|
||||
&json!([input, metadata, hook]).to_string(),
|
||||
timeout_ms,
|
||||
&input.item_id,
|
||||
lease,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(ExtensionError::Timeout) => {
|
||||
return Ok(failed(
|
||||
"postProcess timeout: extension took too long to complete",
|
||||
));
|
||||
}
|
||||
Err(error) => return Ok(failed(error)),
|
||||
};
|
||||
let mut response: Value =
|
||||
serde_json::from_str(&result).map_err(|e| error(e.to_string()))?;
|
||||
Ok(response["value"].take())
|
||||
}
|
||||
|
||||
pub fn run_post_processing(
|
||||
&self,
|
||||
mut input: PostProcessInput,
|
||||
metadata: Map<String, Value>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.check()?;
|
||||
let lease = self.post_process_lease(&input)?;
|
||||
let check = || -> Result<(), ManagerError> {
|
||||
self.check()?;
|
||||
if let Some(lease) = &lease {
|
||||
lease
|
||||
.check_active()
|
||||
.map_err(|failure| error(failure.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
// Go's map iteration has no defined provider order. Use stable IDs;
|
||||
// hooks within each provider retain their manifest order.
|
||||
let providers: Vec<_> = self
|
||||
.entries
|
||||
.lock()
|
||||
.expect("extension manager lock")
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
let status = entry.status.read().expect("extension status lock");
|
||||
status.enabled
|
||||
&& status.error.is_empty()
|
||||
&& entry
|
||||
.manifest
|
||||
.post_processing
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.enabled)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for provider in providers {
|
||||
let config = provider
|
||||
.manifest
|
||||
.post_processing
|
||||
.as_ref()
|
||||
.expect("post-processing config");
|
||||
for hook in &config.hooks {
|
||||
check()?;
|
||||
if !hook.default_enabled {
|
||||
continue;
|
||||
}
|
||||
let mut extension = extension(&input.path);
|
||||
if extension.is_empty() {
|
||||
extension = self::extension(&input.name);
|
||||
}
|
||||
if !hook.supported_formats.is_empty()
|
||||
&& !extension.is_empty()
|
||||
&& !hook.supported_formats.iter().any(|format| {
|
||||
format!(".{format}") == extension || format == &extension[1..]
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = &provider.manifest.name;
|
||||
let log = |message: String| {
|
||||
let _ = self
|
||||
.environment
|
||||
.log_buffer()
|
||||
.backend(&format!("[PostProcessV2] {message}"));
|
||||
};
|
||||
log(format!(
|
||||
"Running hook {} from {id} on {}",
|
||||
hook.id, input.path
|
||||
));
|
||||
let result = match self.post_process_hook(
|
||||
id,
|
||||
&input,
|
||||
&metadata,
|
||||
&hook.id,
|
||||
timeout_ms,
|
||||
lease.clone(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
log(format!("Hook {} failed: {error}", hook.id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
check()?;
|
||||
if result["success"] != true {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = self.validate_post_process_result(&provider, &input, &result) {
|
||||
log(format!(
|
||||
"Hook {} returned an unsafe result: {error}",
|
||||
hook.id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = result["new_file_path"]
|
||||
.as_str()
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
input.path = path.into();
|
||||
if input.name.is_empty() {
|
||||
let clean = clean(Path::new(path));
|
||||
input.name = clean
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or("/".into());
|
||||
}
|
||||
}
|
||||
if let Some(uri) = result["new_file_uri"]
|
||||
.as_str()
|
||||
.filter(|uri| !uri.is_empty())
|
||||
{
|
||||
input.uri = uri.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
check()?;
|
||||
let mut result = json!({"success":true});
|
||||
if !input.path.is_empty() {
|
||||
result["new_file_path"] = input.path.into();
|
||||
}
|
||||
if !input.uri.is_empty() {
|
||||
result["new_file_uri"] = input.uri.into();
|
||||
}
|
||||
Ok(result.to_string())
|
||||
}
|
||||
|
||||
fn validate_post_process_result(
|
||||
&self,
|
||||
entry: &Installed,
|
||||
input: &PostProcessInput,
|
||||
result: &Value,
|
||||
) -> Result<(), ManagerError> {
|
||||
let path = result["new_file_path"].as_str().unwrap_or("");
|
||||
let uri = result["new_file_uri"].as_str().unwrap_or("");
|
||||
if !uri.is_empty() && uri != input.uri {
|
||||
return Err(error("an extension cannot replace the destination URI"));
|
||||
}
|
||||
if path.is_empty() || clean(Path::new(path)) == clean(Path::new(&input.path)) {
|
||||
return Ok(());
|
||||
}
|
||||
if !entry.manifest.permissions.file {
|
||||
return Err(error(
|
||||
"file permission is required to replace the processed file",
|
||||
));
|
||||
}
|
||||
if !Path::new(path).is_absolute() {
|
||||
return Err(error("replacement file path must be absolute"));
|
||||
}
|
||||
self.environment
|
||||
.validate_post_process_path(&entry.manifest.name, &input.path, path)
|
||||
.map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(path: &str) -> String {
|
||||
let name = path.rsplit('/').next().unwrap_or("");
|
||||
name.rfind('.')
|
||||
.map(|index| lowercase(&name[index..]))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extension;
|
||||
|
||||
#[test]
|
||||
fn extension_uses_go_filename_rules_before_path_cleaning() {
|
||||
for (path, expected) in [
|
||||
("song.FLAC", ".flac"),
|
||||
("album.flac/", ""),
|
||||
(".hidden", ".hidden"),
|
||||
("a/.", "."),
|
||||
("a/..", "."),
|
||||
("a\\b.FLAC", ".flac"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(extension(path), expected, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
use super::*;
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::TryLockError;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Priorities {
|
||||
download: Vec<String>,
|
||||
pub(super) metadata: Vec<String>,
|
||||
fallback: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
pub(crate) fn metadata_revision(&self) -> u64 {
|
||||
self.metadata_revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn share_providers(
|
||||
&self,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<spotiflac_core::metadata::share::Provider>, ManagerError> {
|
||||
self.check()?;
|
||||
Ok(self
|
||||
.entries
|
||||
.lock()
|
||||
.expect("extension manager lock")
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
let status = entry.status.read().expect("extension status lock");
|
||||
entry.manifest.name != source_id
|
||||
&& entry.manifest.has_type("metadata_provider")
|
||||
&& status.enabled
|
||||
&& status.error.is_empty()
|
||||
})
|
||||
.map(|entry| spotiflac_core::metadata::share::Provider {
|
||||
id: entry.manifest.name.clone(),
|
||||
display_name: entry.manifest.display_name.clone(),
|
||||
source_dir: entry.source.to_string_lossy().into_owned(),
|
||||
capabilities: json!(entry.manifest.capabilities),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn set_provider_priority(&self, kind: &str, ids: Vec<String>) -> Result<(), ManagerError> {
|
||||
self.check()?;
|
||||
let (provider_type, legacy): (&str, &[&str]) = match kind {
|
||||
"download" => ("download_provider", &["deezer", "qobuz", "tidal"]),
|
||||
"metadata" => (
|
||||
"metadata_provider",
|
||||
&["deezer", "spotify", "qobuz", "tidal"],
|
||||
),
|
||||
_ => return Err(error("unknown provider priority kind")),
|
||||
};
|
||||
let enabled: BTreeSet<_> = self
|
||||
.provider_ids(provider_type)?
|
||||
.into_iter()
|
||||
.map(|id| lowercase(id.trim()))
|
||||
.collect();
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let id = id.trim();
|
||||
let normalized = lowercase(id);
|
||||
if id.is_empty()
|
||||
|| (legacy.contains(&normalized.as_str()) && !enabled.contains(&normalized))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let key = if kind == "download" {
|
||||
normalized
|
||||
} else {
|
||||
id.to_owned()
|
||||
};
|
||||
seen.insert(key).then(|| id.to_owned())
|
||||
})
|
||||
.collect();
|
||||
let mut priorities = self.priorities.lock().expect("provider priority lock");
|
||||
if kind == "download" {
|
||||
priorities.download = ids;
|
||||
} else {
|
||||
priorities.metadata = ids;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_fallback_providers(&self, ids: Option<Vec<String>>) -> Result<(), ManagerError> {
|
||||
self.check()?;
|
||||
let mut seen = BTreeSet::new();
|
||||
self.priorities
|
||||
.lock()
|
||||
.expect("provider priority lock")
|
||||
.fallback = ids.map(|ids| {
|
||||
ids.into_iter()
|
||||
.filter_map(|id| {
|
||||
let id = id.trim();
|
||||
(!id.is_empty() && seen.insert(id.to_owned())).then(|| id.to_owned())
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn provider_priorities(&self) -> Result<String, ManagerError> {
|
||||
self.check()?;
|
||||
let priorities = self.priorities.lock().expect("provider priority lock");
|
||||
Ok(json!({"download":priorities.download,"metadata":priorities.metadata,"fallback":priorities.fallback}).to_string())
|
||||
}
|
||||
|
||||
pub fn fallback_allowed(&self, id: &str) -> Result<bool, ManagerError> {
|
||||
self.check()?;
|
||||
Ok(self
|
||||
.priorities
|
||||
.lock()
|
||||
.expect("provider priority lock")
|
||||
.fallback
|
||||
.as_ref()
|
||||
.is_none_or(|ids| ids.iter().any(|entry| entry == id)))
|
||||
}
|
||||
|
||||
pub fn find_url_handler(&self, url: &str) -> Result<Option<String>, ManagerError> {
|
||||
self.check()?;
|
||||
let mut matches: Vec<_> = self
|
||||
.entries
|
||||
.lock()
|
||||
.expect("extension manager lock")
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
let status = entry.status.read().expect("extension status lock");
|
||||
status.enabled && status.error.is_empty() && entry.manifest.matches_url(url)
|
||||
})
|
||||
.map(|entry| entry.manifest.name.clone())
|
||||
.collect();
|
||||
let priorities = self.priorities.lock().expect("provider priority lock");
|
||||
let ranks: BTreeMap<_, _> = priorities
|
||||
.metadata
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| (lowercase(id.trim()), index))
|
||||
.collect();
|
||||
matches.sort_by_key(|id| {
|
||||
(
|
||||
ranks.get(&lowercase(id)).copied().unwrap_or(usize::MAX),
|
||||
id.clone(),
|
||||
)
|
||||
});
|
||||
Ok(matches.into_iter().next())
|
||||
}
|
||||
|
||||
/// Validate the application metadata wrapper before checking its resource type.
|
||||
pub(crate) fn require_metadata_provider(&self, id: &str) -> Result<(), ManagerError> {
|
||||
let entry = self.get(id)?;
|
||||
if !entry.manifest.has_type("metadata_provider") {
|
||||
return Err(error(format!(
|
||||
"extension '{id}' is not a metadata provider"
|
||||
)));
|
||||
}
|
||||
if !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error(format!("extension '{id}' is disabled")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Typed metadata/lyrics results from installed providers. Download and
|
||||
/// post-processing orchestration use their separate runtime/file lifecycles.
|
||||
pub fn provider_call(
|
||||
&self,
|
||||
id: &str,
|
||||
method: &str,
|
||||
arguments: &str,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<String, ManagerError> {
|
||||
self.provider_operation(id, method, arguments, lease, timeout_ms, "")
|
||||
}
|
||||
|
||||
pub(super) fn provider_operation(
|
||||
&self,
|
||||
id: &str,
|
||||
method: &str,
|
||||
arguments: &str,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
timeout_ms: u64,
|
||||
item_id: &str,
|
||||
) -> Result<String, ManagerError> {
|
||||
let entry = self.get(id)?;
|
||||
let manifest = &entry.manifest;
|
||||
let requirement =
|
||||
match method {
|
||||
"getTrack" | "getAlbum" | "getPlaylist" | "getArtist" | "searchTracks"
|
||||
| "enrichTrack" => (!manifest.has_type("metadata_provider"))
|
||||
.then_some("is not a metadata provider"),
|
||||
"checkAvailability" => (!manifest.has_type("download_provider"))
|
||||
.then_some("is not a download provider"),
|
||||
"fetchLyrics" => {
|
||||
(!manifest.has_type("lyrics_provider")).then_some("is not a lyrics provider")
|
||||
}
|
||||
"customSearch" => (!manifest
|
||||
.search_behavior
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.enabled))
|
||||
.then_some("does not support custom search"),
|
||||
"handleUrl" => (!manifest
|
||||
.url_handler
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.enabled && !config.patterns.is_empty()))
|
||||
.then_some("does not support URL handling"),
|
||||
"getHomeFeed" => None,
|
||||
_ => return Err(error(format!("unsupported provider method: {method}"))),
|
||||
};
|
||||
if let Some(requirement) = requirement {
|
||||
return Err(error(format!("extension '{id}' {requirement}")));
|
||||
}
|
||||
if !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error(format!("extension '{id}' is disabled")));
|
||||
}
|
||||
let mut engine = loop {
|
||||
self.check()?;
|
||||
if let Some(lease) = &lease {
|
||||
lease
|
||||
.check_active()
|
||||
.map_err(|failure| error(failure.to_string()))?;
|
||||
}
|
||||
match entry.engine.try_lock() {
|
||||
Ok(engine) => break engine,
|
||||
Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(5)),
|
||||
Err(TryLockError::Poisoned(_)) => {
|
||||
return Err(error("extension engine lock poisoned"));
|
||||
}
|
||||
}
|
||||
};
|
||||
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
|
||||
return Err(error("extension is disabled or no longer installed"));
|
||||
}
|
||||
let runtime = self
|
||||
.ready_with_lease(&entry, &mut engine, true, lease.clone())
|
||||
.inspect_err(|failure| {
|
||||
if lease
|
||||
.as_ref()
|
||||
.is_none_or(|lease| lease.check_active().is_ok())
|
||||
{
|
||||
self.failed(&entry, failure);
|
||||
}
|
||||
})?;
|
||||
let mut arguments: Vec<Value> = serde_json::from_str(arguments)
|
||||
.map_err(|failure| cause("invalid provider arguments", failure))?;
|
||||
if method == "customSearch" {
|
||||
if arguments.len() < 2 {
|
||||
arguments.resize(2, Value::Null);
|
||||
}
|
||||
if arguments[1].is_null() {
|
||||
arguments[1] = json!({});
|
||||
}
|
||||
}
|
||||
if method == "checkAvailability" {
|
||||
runtime.take_verification_url();
|
||||
}
|
||||
let response = runtime
|
||||
.call_provider_operation(
|
||||
method,
|
||||
&json!(arguments).to_string(),
|
||||
lease,
|
||||
timeout_ms,
|
||||
item_id.into(),
|
||||
)
|
||||
.map_err(|failure| match failure {
|
||||
ExtensionError::Timeout => error(format!(
|
||||
"{method} timeout: extension took too long to respond"
|
||||
)),
|
||||
ExtensionError::Cancelled(cause) => error(cause.to_string()),
|
||||
_ if method == "checkAvailability"
|
||||
&& !runtime.take_verification_url().is_empty() =>
|
||||
{
|
||||
cause("checkAvailability failed", verification_error(id))
|
||||
}
|
||||
other => cause(&format!("{method} failed"), other),
|
||||
})?;
|
||||
let mut response: Value =
|
||||
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
|
||||
if let Some(message) = response.get("parseError").and_then(Value::as_str) {
|
||||
if method == "getHomeFeed" {
|
||||
return Err(error(format!("failed to marshal result: {message}")));
|
||||
}
|
||||
let kind = match method {
|
||||
"searchTracks" | "customSearch" => "search result",
|
||||
"getAlbum" => "album",
|
||||
"getPlaylist" => "playlist",
|
||||
"getArtist" => "artist",
|
||||
"handleUrl" => "URL handle result",
|
||||
"fetchLyrics" => "lyrics result",
|
||||
"checkAvailability" => "availability result",
|
||||
_ => "track",
|
||||
};
|
||||
return Err(error(format!("failed to parse {kind}: {message}")));
|
||||
}
|
||||
let mut value = response["value"].take();
|
||||
if method == "checkAvailability" && (value.is_null() || value["available"] != true) {
|
||||
if !runtime.take_verification_url().is_empty() {
|
||||
return Err(verification_error(id));
|
||||
}
|
||||
if value.is_null() {
|
||||
return Ok(json!({"available":false,"reason":"not implemented"}).to_string());
|
||||
}
|
||||
}
|
||||
if value.is_null() {
|
||||
return if method == "customSearch" {
|
||||
Ok("[]".into())
|
||||
} else if method == "handleUrl" {
|
||||
Err(error("handleUrl returned null - URL not recognized"))
|
||||
} else {
|
||||
Err(error(format!("{method} returned null")))
|
||||
};
|
||||
}
|
||||
match method {
|
||||
"getTrack" => stamp(&mut value, id),
|
||||
"getAlbum" | "getPlaylist" => stamp_album(&mut value, id),
|
||||
"searchTracks" => stamp_tracks(&mut value["tracks"], id),
|
||||
"customSearch" => stamp_tracks(&mut value, id),
|
||||
"getArtist" => {
|
||||
// Go stamps releases here; albums/top tracks keep their supplied
|
||||
// provider fields until URL handling performs recursive stamping.
|
||||
stamp(&mut value, id);
|
||||
if let Some(releases) = value.get_mut("releases").and_then(Value::as_array_mut) {
|
||||
for release in releases {
|
||||
stamp_album(release, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"handleUrl" => {
|
||||
if let Some(track) = value.get_mut("track") {
|
||||
stamp(track, id);
|
||||
}
|
||||
if let Some(tracks) = value.get_mut("tracks") {
|
||||
stamp_tracks(tracks, id);
|
||||
}
|
||||
if let Some(album) = value.get_mut("album") {
|
||||
stamp_album(album, id);
|
||||
}
|
||||
if let Some(artist) = value.get_mut("artist") {
|
||||
stamp(artist, id);
|
||||
for key in ["albums", "releases"] {
|
||||
if let Some(albums) = artist.get_mut(key).and_then(Value::as_array_mut) {
|
||||
for album in albums {
|
||||
stamp_album(album, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tracks) = artist.get_mut("top_tracks") {
|
||||
stamp_tracks(tracks, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"fetchLyrics" => {
|
||||
value["source"] = format!("Extension: {id}").into();
|
||||
if value["provider"] == "" {
|
||||
value["provider"] = manifest.display_name.clone().into();
|
||||
}
|
||||
if value["lines"].as_array().is_some_and(Vec::is_empty) {
|
||||
let plain = value["plainLyrics"].as_str().unwrap_or("").to_owned();
|
||||
value["lines"] = Value::Null;
|
||||
if !plain.is_empty() && value["instrumental"] != true {
|
||||
value["syncType"] = "UNSYNCED".into();
|
||||
let lines: Vec<_> = plain
|
||||
.split('\n')
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| json!({"startTimeMs":0,"words":line,"endTimeMs":0}))
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
value["lines"] = lines.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn verification_error(id: &str) -> ManagerError {
|
||||
error(format!(
|
||||
"verification_required: extension '{id}' needs signed-session verification"
|
||||
))
|
||||
}
|
||||
|
||||
fn stamp(value: &mut Value, id: &str) {
|
||||
value["provider_id"] = id.into();
|
||||
}
|
||||
fn stamp_tracks(value: &mut Value, id: &str) {
|
||||
if let Some(tracks) = value.as_array_mut() {
|
||||
for track in tracks {
|
||||
stamp(track, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn stamp_album(value: &mut Value, id: &str) {
|
||||
stamp(value, id);
|
||||
stamp_tracks(&mut value["tracks"], id);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! 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<T> GoNull for Vec<T> {
|
||||
fn set_null(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
impl<T> GoNull for Option<T> {
|
||||
fn set_null(&mut self) {
|
||||
*self = None;
|
||||
}
|
||||
}
|
||||
impl GoNull for Map<String, Value> {
|
||||
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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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<E: serde::de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok($name::default())
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut result = $name::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
$(
|
||||
if key.eq_ignore_ascii_case($wire) {
|
||||
match map.next_value::<Option<$ty>>()? {
|
||||
Some(value) => result.$field = value,
|
||||
None => result.$field.set_null(),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
)*
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
manifest_struct!(ExtensionPermissions {
|
||||
network: Option<Vec<String>> => "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<String> => "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<String> => "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<QualitySpecificSetting> => "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<SearchFilter> => "filters",
|
||||
});
|
||||
|
||||
manifest_struct!(UrlHandler {
|
||||
enabled: bool => "enabled",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
patterns: Vec<String> => "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<String> => "supportedFormats",
|
||||
});
|
||||
|
||||
manifest_struct!(PostProcessing {
|
||||
enabled: bool => "enabled",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
hooks: Vec<PostProcessingHook> => "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<String> => "type",
|
||||
permissions: ExtensionPermissions => "permissions",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
settings: Vec<ExtensionSetting> => "settings",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
quality_options: Vec<QualityOption> => "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> => "searchBehavior",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url_handler: Option<UrlHandler> => "urlHandler",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
track_matching: Option<TrackMatching> => "trackMatching",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
post_processing: Option<PostProcessing> => "postProcessing",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
service_health: Vec<HealthCheck> => "serviceHealth",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
signed_session: Option<SignedSession> => "signedSession",
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
required_runtime_features: Vec<String> => "requiredRuntimeFeatures",
|
||||
#[serde(skip_serializing_if = "Map::is_empty")]
|
||||
capabilities: Map<String, Value> => "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<String>, message: impl Into<String>) -> 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<Self, ManifestError> {
|
||||
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<String, String> {
|
||||
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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use crate::host::decode_go_utf8;
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use base64::Engine;
|
||||
use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value};
|
||||
use spotiflac_network::{HttpRequest, HttpResponse};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
host.set("networkEnabled", services.network.is_some())?;
|
||||
let Some(session) = &services.network else {
|
||||
return Ok(());
|
||||
};
|
||||
let validator = Arc::clone(session);
|
||||
host.set(
|
||||
"validateURL",
|
||||
Function::new(ctx.clone(), move |url: String| {
|
||||
validator.validate_url(&url).err()
|
||||
})?,
|
||||
)?;
|
||||
let clearer = Arc::clone(session);
|
||||
host.set(
|
||||
"clearCookies",
|
||||
Function::new(ctx.clone(), move || {
|
||||
clearer.clear_cookies();
|
||||
true
|
||||
})?,
|
||||
)?;
|
||||
let session = Arc::clone(session);
|
||||
host.set(
|
||||
"request",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |url: String,
|
||||
method: String,
|
||||
body: String,
|
||||
headers: Object<'js>,
|
||||
default_json: bool,
|
||||
user_agent: String,
|
||||
fetch: bool|
|
||||
-> rquickjs::Result<Object<'js>> {
|
||||
let ctx = headers.ctx().clone();
|
||||
let headers = headers
|
||||
.props::<String, String>()
|
||||
.collect::<rquickjs::Result<BTreeMap<_, _>>>()?;
|
||||
let result = session.request(
|
||||
HttpRequest {
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
default_json,
|
||||
user_agent,
|
||||
},
|
||||
|| control.check().map_err(|error| error.to_string()),
|
||||
);
|
||||
match result {
|
||||
Ok(response) => response_object(&ctx, response, fetch),
|
||||
Err(error) => {
|
||||
let object = Object::new(ctx)?;
|
||||
object.set("error", error)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
host.set(
|
||||
"decodeBuffer",
|
||||
Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| {
|
||||
decode_buffer(&bytes)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"parseJSONBuffer",
|
||||
Function::new(ctx.clone(), |ctx: Ctx<'js>, bytes: TypedArray<'js, u8>| {
|
||||
parse_json(&ctx, &decode_buffer(&bytes))
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"encodeBuffer",
|
||||
Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| {
|
||||
encode_buffer(&bytes)
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_json<'js>(ctx: &Ctx<'js>, text: &str) -> rquickjs::Result<Value<'js>> {
|
||||
let text = spotiflac_core::normalize_json_surrogates(text);
|
||||
let bytes = text.as_bytes();
|
||||
let (mut position, mut depth) = (0, 0usize);
|
||||
// Go validates every float64 before overwriting duplicate object keys.
|
||||
// The engine supplies syntax validation and allocates directly in its heap.
|
||||
while position < bytes.len() {
|
||||
match bytes[position] {
|
||||
b'"' => {
|
||||
position += 1;
|
||||
while position < bytes.len() && bytes[position] != b'"' {
|
||||
position += if bytes[position] == b'\\' { 2 } else { 1 };
|
||||
}
|
||||
}
|
||||
b'[' | b'{' => {
|
||||
depth += 1;
|
||||
if depth > 10_000 {
|
||||
return Err(rquickjs::Exception::throw_syntax(
|
||||
ctx,
|
||||
"JSON exceeds maximum depth",
|
||||
));
|
||||
}
|
||||
}
|
||||
b']' | b'}' => depth = depth.saturating_sub(1),
|
||||
b'-' | b'0'..=b'9' => {
|
||||
let start = position;
|
||||
while position < bytes.len()
|
||||
&& matches!(
|
||||
bytes[position],
|
||||
b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E'
|
||||
)
|
||||
{
|
||||
position += 1;
|
||||
}
|
||||
if !text[start..position]
|
||||
.parse::<f64>()
|
||||
.is_ok_and(f64::is_finite)
|
||||
{
|
||||
return Err(rquickjs::Exception::throw_syntax(
|
||||
ctx,
|
||||
"invalid JSON number",
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
position += 1;
|
||||
}
|
||||
ctx.json_parse(text.as_bytes())
|
||||
}
|
||||
|
||||
fn response_object<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
response: HttpResponse,
|
||||
fetch: bool,
|
||||
) -> rquickjs::Result<Object<'js>> {
|
||||
let object = Object::new(ctx.clone())?;
|
||||
object.set("status", response.status)?;
|
||||
object.set("ok", (200..300).contains(&response.status))?;
|
||||
object.set("url", response.url)?;
|
||||
let headers = Object::new(ctx.clone())?;
|
||||
for (key, values) in response.headers {
|
||||
if values.len() == 1 {
|
||||
headers.set(key, &values[0])?;
|
||||
} else {
|
||||
let array = Array::new(ctx.clone())?;
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
array.set(index, value)?;
|
||||
}
|
||||
headers.set(key, array)?;
|
||||
}
|
||||
}
|
||||
object.set("headers", headers)?;
|
||||
if fetch {
|
||||
object.set("statusText", response.status_text)?;
|
||||
// Copy into engine-owned memory so retained responses count against the
|
||||
// VM heap limit. No base64 intermediate or per-byte JS Number array.
|
||||
object.set("bytes", TypedArray::new_copy(ctx.clone(), response.body)?)?;
|
||||
} else {
|
||||
object.set("statusCode", response.status)?;
|
||||
object.set("body", decode_go_utf8(&response.body))?;
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn decode_buffer(bytes: &TypedArray<'_, u8>) -> String {
|
||||
// SAFETY: the VM has one worker and exposes no shared-memory workers. No
|
||||
// JavaScript or engine operation runs while borrowing its buffer. The owned
|
||||
// Rust String is completed before returning through rquickjs into JavaScript.
|
||||
let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default();
|
||||
decode_go_utf8(bytes)
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn encode_buffer(bytes: &TypedArray<'_, u8>) -> String {
|
||||
// SAFETY: same single-worker, no-engine-call borrowing rule as decode_buffer.
|
||||
let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default();
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Inspect before extraction; only a private staging directory receives files.
|
||||
|
||||
use crate::host::decode_go_utf8;
|
||||
use crate::manifest::ExtensionManifest;
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zip::ZipArchive;
|
||||
|
||||
const MAX_ENTRIES: u64 = 2048;
|
||||
const MAX_EXTRACTED: u64 = 256 * 1024 * 1024;
|
||||
const MAX_MANIFEST: u64 = 1024 * 1024;
|
||||
const INVALID_ARCHIVE: &str =
|
||||
"cannot open extension file: the file may be corrupted or not a valid extension package";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct PackageError(pub String);
|
||||
|
||||
fn error(message: impl Into<String>) -> PackageError {
|
||||
PackageError(message.into())
|
||||
}
|
||||
fn corrupt(_: impl std::fmt::Display) -> PackageError {
|
||||
error(INVALID_ARCHIVE)
|
||||
}
|
||||
fn io_error(message: &str, cause: impl std::fmt::Display) -> PackageError {
|
||||
error(format!("{message}: {cause}"))
|
||||
}
|
||||
|
||||
pub fn is_package_path(path: &Path) -> bool {
|
||||
let name = lowercase(&path.to_string_lossy());
|
||||
name.ends_with(".spotiflac-ext") || name.ends_with(".sflx")
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
path: PathBuf,
|
||||
directory: bool,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
pub struct ExtensionPackage {
|
||||
archive: ZipArchive<File>,
|
||||
entries: Vec<Entry>,
|
||||
pub manifest: ExtensionManifest,
|
||||
pub manifest_json: String,
|
||||
}
|
||||
|
||||
impl ExtensionPackage {
|
||||
pub fn open(path: &Path) -> Result<Self, PackageError> {
|
||||
if !is_package_path(path) {
|
||||
return Err(error(
|
||||
"invalid file format: please select a .spotiflac-ext or .sflx file",
|
||||
));
|
||||
}
|
||||
let mut input = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32)
|
||||
.open(path)
|
||||
.map_err(corrupt)?;
|
||||
if !input.metadata().map_err(corrupt)?.is_file() {
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
// zip-rs coalesces duplicate raw names. Inspect the original central
|
||||
// directory first, and bound its entry count before it allocates metadata.
|
||||
let names = central_names(&mut input)?;
|
||||
let mut archive = ZipArchive::new(input).map_err(corrupt)?;
|
||||
if archive.len() != names.len() {
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
let mut entries = Vec::with_capacity(names.len());
|
||||
let mut total = 0u64;
|
||||
let mut manifest_index = None;
|
||||
let mut has_index = false;
|
||||
for (index, name) in names.into_iter().enumerate() {
|
||||
let file = archive.by_index_raw(index).map_err(corrupt)?;
|
||||
let mode = file.unix_mode().unwrap_or(0) & 0o170000;
|
||||
if mode == 0o120000 {
|
||||
return Err(error(format!(
|
||||
"unsafe path in extension archive: {}",
|
||||
decode_go_utf8(&name)
|
||||
)));
|
||||
}
|
||||
let path = clean_path(&name)?;
|
||||
let directory = name.ends_with(b"/") || mode == 0o040000;
|
||||
if !directory {
|
||||
if file.size() > MAX_EXTRACTED - total {
|
||||
return Err(error(
|
||||
"extension archive exceeds the 256 MiB extracted size limit",
|
||||
));
|
||||
}
|
||||
total += file.size();
|
||||
}
|
||||
if path == Path::new("manifest.json") && !directory {
|
||||
manifest_index = Some(index);
|
||||
}
|
||||
if path == Path::new("index.js") && !directory {
|
||||
has_index = true;
|
||||
}
|
||||
entries.push(Entry {
|
||||
path,
|
||||
directory,
|
||||
size: file.size(),
|
||||
});
|
||||
}
|
||||
let index = manifest_index
|
||||
.ok_or_else(|| error("invalid extension package: root manifest.json not found"))?;
|
||||
if !has_index {
|
||||
return Err(error("invalid extension package: root index.js not found"));
|
||||
}
|
||||
if entries[index].size > MAX_MANIFEST {
|
||||
return Err(error(
|
||||
"invalid extension package: manifest.json is too large",
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
archive
|
||||
.by_index(index)
|
||||
.map_err(|e| io_error("failed to open manifest.json", e))?
|
||||
.take(MAX_MANIFEST + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| io_error("failed to read manifest.json", e))?;
|
||||
if bytes.len() as u64 > MAX_MANIFEST {
|
||||
return Err(error(
|
||||
"invalid extension package: manifest.json is too large",
|
||||
));
|
||||
}
|
||||
let manifest_json = decode_go_utf8(&bytes);
|
||||
let manifest = ExtensionManifest::parse(&manifest_json)
|
||||
.map_err(|e| io_error("invalid extension manifest", e))?;
|
||||
Ok(Self {
|
||||
archive,
|
||||
entries,
|
||||
manifest,
|
||||
manifest_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract(
|
||||
&mut self,
|
||||
destination: &Path,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), PackageError> {
|
||||
let metadata = fs::symlink_metadata(destination)
|
||||
.map_err(|e| io_error("failed to inspect staging directory", e))?;
|
||||
if !metadata.is_dir() || fs::read_dir(destination).map_err(corrupt)?.next().is_some() {
|
||||
return Err(error(
|
||||
"extension extraction requires an empty staging directory",
|
||||
));
|
||||
}
|
||||
let mut total = 0u64;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
for (index, entry) in self.entries.iter().enumerate() {
|
||||
check().map_err(error)?;
|
||||
if entry.directory {
|
||||
continue;
|
||||
}
|
||||
let path = destination.join(&entry.path);
|
||||
fs::create_dir_all(path.parent().expect("staging parent"))
|
||||
.map_err(|e| io_error("failed to create extension directory", e))?;
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| io_error("failed to create extension file", e))?;
|
||||
let mut input = self
|
||||
.archive
|
||||
.by_index(index)
|
||||
.map_err(|e| io_error("failed to open file in archive", e))?;
|
||||
let mut written = 0u64;
|
||||
loop {
|
||||
check().map_err(error)?;
|
||||
let count = input
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| io_error("failed to extract extension file", e))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
if count as u64 > MAX_EXTRACTED - total
|
||||
|| count as u64 > entry.size.saturating_sub(written)
|
||||
{
|
||||
return Err(error(
|
||||
"extension archive exceeds its declared extracted size",
|
||||
));
|
||||
}
|
||||
output
|
||||
.write_all(&buffer[..count])
|
||||
.map_err(|e| io_error("failed to extract extension file", e))?;
|
||||
total += count as u64;
|
||||
written += count as u64;
|
||||
}
|
||||
if written != entry.size {
|
||||
return Err(error("failed to extract extension file: unexpected EOF"));
|
||||
}
|
||||
output
|
||||
.sync_all()
|
||||
.map_err(|e| io_error("failed to close extracted extension file", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_path(name: &[u8]) -> Result<PathBuf, PackageError> {
|
||||
let unsafe_path = || {
|
||||
error(format!(
|
||||
"unsafe path in extension archive: {}",
|
||||
decode_go_utf8(name)
|
||||
))
|
||||
};
|
||||
if name.starts_with(b"/") || name.contains(&b'\\') || name.contains(&0) {
|
||||
return Err(unsafe_path());
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for part in name.split(|byte| *byte == b'/') {
|
||||
match part {
|
||||
b"" | b"." => {}
|
||||
b".." => {
|
||||
if parts.pop().is_none() {
|
||||
return Err(unsafe_path());
|
||||
}
|
||||
}
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(unsafe_path());
|
||||
}
|
||||
Ok(PathBuf::from(OsString::from_vec(parts.join(&b'/'))))
|
||||
}
|
||||
|
||||
fn number16(bytes: &[u8], offset: usize) -> u64 {
|
||||
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) as u64
|
||||
}
|
||||
fn number32(bytes: &[u8], offset: usize) -> u64 {
|
||||
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as u64
|
||||
}
|
||||
fn number64(bytes: &[u8], offset: usize) -> u64 {
|
||||
u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())
|
||||
}
|
||||
fn read_at<const N: usize>(input: &mut File, offset: u64) -> Result<[u8; N], PackageError> {
|
||||
input.seek(SeekFrom::Start(offset)).map_err(corrupt)?;
|
||||
let mut bytes = [0; N];
|
||||
input.read_exact(&mut bytes).map_err(corrupt)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn central_names(input: &mut File) -> Result<Vec<Vec<u8>>, PackageError> {
|
||||
let length = input.metadata().map_err(corrupt)?.len();
|
||||
let tail_size = length.min(65557) as usize;
|
||||
let mut tail = vec![0; tail_size];
|
||||
input
|
||||
.seek(SeekFrom::Start(length - tail_size as u64))
|
||||
.map_err(corrupt)?;
|
||||
input.read_exact(&mut tail).map_err(corrupt)?;
|
||||
let end = (0..tail.len().saturating_sub(21))
|
||||
.rev()
|
||||
.find(|index| {
|
||||
tail[*index..].starts_with(b"PK\x05\x06")
|
||||
&& *index + 22 + number16(&tail, *index + 20) as usize <= tail.len()
|
||||
})
|
||||
.ok_or_else(|| error(INVALID_ARCHIVE))?;
|
||||
let absolute_end = length - tail_size as u64 + end as u64;
|
||||
let mut count = number16(&tail, end + 10);
|
||||
let mut size = number32(&tail, end + 12);
|
||||
let mut offset = number32(&tail, end + 16);
|
||||
let mut directory_end = absolute_end;
|
||||
if absolute_end >= 20 {
|
||||
let locator = read_at::<20>(input, absolute_end - 20)?;
|
||||
if locator.starts_with(b"PK\x06\x07") {
|
||||
let record_offset = number64(&locator, 8);
|
||||
let record = read_at::<56>(input, record_offset)?;
|
||||
if !record.starts_with(b"PK\x06\x06") {
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
count = number64(&record, 32);
|
||||
size = number64(&record, 40);
|
||||
offset = number64(&record, 48);
|
||||
directory_end = record_offset;
|
||||
}
|
||||
}
|
||||
if count > size / 46 {
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
if count > MAX_ENTRIES {
|
||||
return Err(error(
|
||||
"extension archive contains too many entries (maximum 2048)",
|
||||
));
|
||||
}
|
||||
let inferred = directory_end
|
||||
.checked_sub(size)
|
||||
.ok_or_else(|| error(INVALID_ARCHIVE))?;
|
||||
let mut position = inferred;
|
||||
if offset < length && read_at::<4>(input, offset)? == *b"PK\x01\x02" {
|
||||
position = offset;
|
||||
}
|
||||
let mut names = Vec::with_capacity(count as usize);
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..count {
|
||||
let header = read_at::<46>(input, position)?;
|
||||
if !header.starts_with(b"PK\x01\x02") {
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
let name_length = number16(&header, 28);
|
||||
let mut name = vec![0; name_length as usize];
|
||||
input.read_exact(&mut name).map_err(corrupt)?;
|
||||
let normalized = clean_path(&name)?;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let key = lowercase(&decode_go_utf8(normalized.as_os_str().as_bytes()));
|
||||
if !seen.insert(key) {
|
||||
return Err(error(format!(
|
||||
"duplicate path in extension archive: {}",
|
||||
decode_go_utf8(&name)
|
||||
)));
|
||||
}
|
||||
position = position
|
||||
.checked_add(46 + name_length + number16(&header, 30) + number16(&header, 32))
|
||||
.filter(|value| *value <= directory_end)
|
||||
.ok_or_else(|| error(INVALID_ARCHIVE))?;
|
||||
names.push(name);
|
||||
}
|
||||
if position
|
||||
.checked_add(4)
|
||||
.is_some_and(|end| end <= directory_end)
|
||||
&& read_at::<4>(input, position)? == *b"PK\x01\x02"
|
||||
{
|
||||
return Err(error(INVALID_ARCHIVE));
|
||||
}
|
||||
input.rewind().map_err(corrupt)?;
|
||||
Ok(names)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
(function (host) {
|
||||
"use strict";
|
||||
const symbolDescription = Object.getOwnPropertyDescriptor(Symbol.prototype, "description").get;
|
||||
const stringApply = Reflect.apply;
|
||||
const primitiveSymbol = Symbol.toPrimitive;
|
||||
const boxedStringValue = String.prototype.valueOf;
|
||||
const boxedNumberValue = Number.prototype.valueOf;
|
||||
const boxedBooleanValue = Boolean.prototype.valueOf;
|
||||
function isPrimitive(value) {
|
||||
return value === null || (typeof value !== "object" && typeof value !== "function");
|
||||
}
|
||||
function goString(value) {
|
||||
// Goja Value.String accepts a Symbol returned by an object's coercion.
|
||||
if (!isPrimitive(value)) {
|
||||
const exotic = value[primitiveSymbol];
|
||||
if (exotic !== undefined && exotic !== null) {
|
||||
value = stringApply(exotic, value, ["string"]);
|
||||
if (!isPrimitive(value)) throw new TypeError("cannot convert object to primitive value");
|
||||
} else {
|
||||
let converted = false;
|
||||
for (const key of ["toString", "valueOf"]) {
|
||||
const method = value[key];
|
||||
if (typeof method !== "function") continue;
|
||||
const result = stringApply(method, value, []);
|
||||
if (isPrimitive(result)) { value = result; converted = true; break; }
|
||||
}
|
||||
if (!converted) throw new TypeError("cannot convert object to primitive value");
|
||||
}
|
||||
}
|
||||
if (typeof value === "symbol") return (stringApply(symbolDescription, value, []) || "").toWellFormed();
|
||||
return String(value).toWellFormed();
|
||||
}
|
||||
function goNumberPrimitive(value) {
|
||||
if (isPrimitive(value)) return value;
|
||||
const exotic = value[primitiveSymbol];
|
||||
if (exotic !== undefined && exotic !== null) {
|
||||
const result = stringApply(exotic, value, ["number"]);
|
||||
if (isPrimitive(result)) return result;
|
||||
} else {
|
||||
for (const key of ["valueOf", "toString"]) {
|
||||
const method = value[key];
|
||||
if (typeof method !== "function") continue;
|
||||
const result = stringApply(method, value, []);
|
||||
if (isPrimitive(result)) return result;
|
||||
}
|
||||
}
|
||||
throw new TypeError("cannot convert object to primitive value");
|
||||
}
|
||||
function isMap(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
&& !ArrayBuffer.isView(value) && !(value instanceof Date)
|
||||
&& !(value instanceof Map) && !(value instanceof Set);
|
||||
}
|
||||
function formatGo(value) {
|
||||
if (value === null || value === undefined) return "<nil>";
|
||||
if (typeof value === "number") {
|
||||
if (Number.isNaN(value)) return "NaN";
|
||||
if (!Number.isFinite(value)) return value < 0 ? "-Inf" : "+Inf";
|
||||
if (Object.is(value, -0)) return "-0";
|
||||
}
|
||||
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
||||
return "[" + Array.from(value, formatGo).join(" ") + "]";
|
||||
}
|
||||
if (isMap(value)) {
|
||||
return "map[" + host.sortKeys(Object.keys(value))
|
||||
.map(key => goString(key) + ":" + formatGo(value[key])).join(" ") + "]";
|
||||
}
|
||||
return goString(value);
|
||||
}
|
||||
const byteArrays = new WeakSet();
|
||||
const responseByteArrays = new WeakMap();
|
||||
function emptyBytes() {
|
||||
const bytes = [];
|
||||
byteArrays.add(bytes);
|
||||
return bytes;
|
||||
}
|
||||
function exportValue(value, ancestors) {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("unsupported JSON number");
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") return value.toWellFormed();
|
||||
if (typeof value !== "object") throw new TypeError("unsupported JSON value");
|
||||
let stringObject;
|
||||
try { stringObject = stringApply(boxedStringValue, value, []); } catch (_) {}
|
||||
let primitive;
|
||||
try { primitive = stringApply(boxedBooleanValue, value, []); } catch (_) {}
|
||||
if (typeof primitive === "boolean") return primitive;
|
||||
try { primitive = stringApply(boxedNumberValue, value, []); } catch (_) {}
|
||||
if (typeof primitive === "number") return exportValue(primitive, ancestors);
|
||||
if (ancestors.length >= 128 || ancestors.includes(value)) throw new TypeError("cyclic or deeply nested JSON value");
|
||||
if (responseByteArrays.has(value)) return host.encodeBuffer(responseByteArrays.get(value));
|
||||
if (byteArrays.has(value) || value instanceof Uint8Array) return host.encodeBytes(Array.from(value));
|
||||
if (value instanceof Date) {
|
||||
if (!Number.isFinite(value.getTime())) return null;
|
||||
const pad = (number, width = 2) => String(number).padStart(width, "0");
|
||||
const year = value.getFullYear();
|
||||
if (year < 0 || year > 9999) throw new TypeError("unsupported JSON date");
|
||||
const offset = -value.getTimezoneOffset();
|
||||
const zone = offset === 0 ? "Z" : (offset < 0 ? "-" : "+")
|
||||
+ pad(Math.trunc(Math.abs(offset) / 60)) + ":" + pad(Math.abs(offset) % 60);
|
||||
const milliseconds = value.getMilliseconds();
|
||||
const fraction = milliseconds ? "." + pad(milliseconds, 3).replace(/0+$/, "") : "";
|
||||
return pad(year, 4) + "-" + pad(value.getMonth() + 1) + "-" + pad(value.getDate())
|
||||
+ "T" + pad(value.getHours()) + ":" + pad(value.getMinutes()) + ":"
|
||||
+ pad(value.getSeconds()) + fraction + zone;
|
||||
}
|
||||
if (value instanceof Map) throw new TypeError("unsupported JSON map");
|
||||
ancestors.push(value);
|
||||
let result;
|
||||
if (Array.isArray(value) || ArrayBuffer.isView(value) || value instanceof Set) {
|
||||
result = Array.from(value, item => exportValue(item, ancestors));
|
||||
} else {
|
||||
result = Object.create(null);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
|
||||
result[key.toWellFormed()] = exportValue(value[key], ancestors);
|
||||
}
|
||||
}
|
||||
ancestors.pop();
|
||||
return result;
|
||||
}
|
||||
function serialize(value) {
|
||||
// Goja exports values before encoding/json. That retains undefined object
|
||||
// properties as null, encodes byte slices as base64, and ignores toJSON.
|
||||
function encode(value) {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return host.quoteJSON(value);
|
||||
if (typeof value === "number") return Object.is(value, -0) ? "-0" : String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return "[" + value.map(encode).join(",") + "]";
|
||||
const fields = host.sortKeys(Object.keys(value))
|
||||
.map(key => host.quoteJSON(key) + ":" + encode(value[key]));
|
||||
return "{" + fields.join(",") + "}";
|
||||
}
|
||||
return encode(exportValue(value, []));
|
||||
}
|
||||
let registered = false;
|
||||
globalThis.registerExtension = function (value) {
|
||||
if (arguments.length) {
|
||||
registered = value !== undefined;
|
||||
globalThis.extension = value;
|
||||
}
|
||||
};
|
||||
|
||||
if (host.storageEnabled) {
|
||||
function read(credentials, key, fallback) {
|
||||
const result = JSON.parse(host.storageRead(credentials, goString(key)));
|
||||
if (result.error !== undefined) return undefined;
|
||||
return result.found ? result.value : fallback;
|
||||
}
|
||||
function write(credentials, key, value) {
|
||||
try {
|
||||
return JSON.parse(host.storageWrite(credentials, goString(key), serialize(value)));
|
||||
} catch (error) {
|
||||
return {success: false, error: goString(error)};
|
||||
}
|
||||
}
|
||||
globalThis.storage = {
|
||||
get(key, fallback) { return arguments.length ? read(false, key, fallback) : undefined; },
|
||||
set(key, value) { return arguments.length >= 2 && write(false, key, value).success; },
|
||||
remove(key) {
|
||||
return arguments.length > 0 && JSON.parse(host.storageRemove(false, goString(key))).success;
|
||||
}
|
||||
};
|
||||
globalThis.credentials = {
|
||||
get(key, fallback) { return arguments.length ? read(true, key, fallback) : undefined; },
|
||||
store(key, value) {
|
||||
return arguments.length < 2 ? {success: false, error: "key and value are required"} : write(true, key, value);
|
||||
},
|
||||
remove(key) {
|
||||
return arguments.length > 0 && JSON.parse(host.storageRemove(true, goString(key))).success;
|
||||
},
|
||||
has(key) {
|
||||
return arguments.length > 0 && JSON.parse(host.storageRead(true, goString(key))).found === true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (host.networkEnabled) {
|
||||
function responseBytes(bytes) {
|
||||
// Go exposes []byte as a fresh, array-like object sharing the body.
|
||||
// A sparse array proxy preserves that shape without a Number slot
|
||||
// per byte; the backing Uint8Array remains charged to the VM heap.
|
||||
const array = [];
|
||||
array.length = bytes.length;
|
||||
function index(key) {
|
||||
if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key)) return -1;
|
||||
const value = Number(key);
|
||||
return value < bytes.length ? value : -1;
|
||||
}
|
||||
const proxy = new Proxy(array, {
|
||||
get(target, key, receiver) {
|
||||
const offset = index(key);
|
||||
return offset >= 0 && offset < target.length ? bytes[offset] : Reflect.get(target, key, receiver);
|
||||
},
|
||||
set(target, key, value) {
|
||||
const offset = index(key);
|
||||
if (offset >= 0) { bytes[offset] = value; return true; }
|
||||
return Reflect.set(target, key, value);
|
||||
},
|
||||
has(target, key) { return (index(key) >= 0 && index(key) < target.length) || Reflect.has(target, key); },
|
||||
ownKeys(target) {
|
||||
return Array.from({length: Math.min(target.length, bytes.length)}, (_, i) => String(i))
|
||||
.concat(Reflect.ownKeys(target).filter(key => index(key) < 0));
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
const offset = index(key);
|
||||
return offset >= 0 && offset < target.length
|
||||
? {value: bytes[offset], writable: true, enumerable: true, configurable: true}
|
||||
: Reflect.getOwnPropertyDescriptor(target, key);
|
||||
}
|
||||
});
|
||||
byteArrays.add(proxy);
|
||||
responseByteArrays.set(proxy, bytes);
|
||||
return proxy;
|
||||
}
|
||||
function bodyString(value, exported) {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return goString(value);
|
||||
if (isMap(value) || (Array.isArray(value) && !byteArrays.has(value))) return serialize(value);
|
||||
return exported ? formatGo(value) : goString(value);
|
||||
}
|
||||
function requestHeaders(value) {
|
||||
const headers = Object.create(null);
|
||||
if (isMap(value)) {
|
||||
for (const key of Object.keys(value)) headers[goString(key)] = formatGo(value[key]);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
function fetchError(error) {
|
||||
return {ok: false, status: 0, statusText: "Network Error", error,
|
||||
text() { return ""; }, json() { return undefined; }};
|
||||
}
|
||||
function dispatch(args, method, fetch, options) {
|
||||
const fail = error => fetch ? fetchError(error) : {error};
|
||||
if (!args.length) return fail("URL is required");
|
||||
const url = goString(args[0]);
|
||||
const denied = host.validateURL(url);
|
||||
if (denied !== undefined && denied !== null) return fail(denied);
|
||||
let body = "", headers = {};
|
||||
try {
|
||||
if (options && isMap(args[1])) {
|
||||
const opts = args[1];
|
||||
if (typeof opts.method === "string") method = goString(opts.method).toUpperCase();
|
||||
body = bodyString(opts.body, true);
|
||||
headers = requestHeaders(opts.headers);
|
||||
} else if (!options) {
|
||||
if (method === "GET" || method === "DELETE") headers = requestHeaders(args[1]);
|
||||
else {
|
||||
body = bodyString(args[1], false);
|
||||
headers = requestHeaders(args[2]);
|
||||
}
|
||||
}
|
||||
} catch (error) { return fail("failed to stringify body: " + goString(error)); }
|
||||
const result = host.request(url, method, body, headers,
|
||||
(!options && method === "POST") || body !== "",
|
||||
fetch ? host.appUserAgent() : "Spotiflac-Extension/1.0", fetch);
|
||||
if (result.error !== undefined) return fail(result.error);
|
||||
if (!fetch) return result;
|
||||
const bytes = result.bytes;
|
||||
delete result.bytes;
|
||||
let text;
|
||||
result.text = function () {
|
||||
if (text === undefined) text = host.decodeBuffer(bytes);
|
||||
return text;
|
||||
};
|
||||
result.json = function () {
|
||||
try { return host.parseJSONBuffer(bytes); }
|
||||
catch (_) { return undefined; }
|
||||
};
|
||||
result.arrayBuffer = function () { return responseBytes(bytes); };
|
||||
return result;
|
||||
}
|
||||
globalThis.http = {
|
||||
get() { return dispatch(arguments, "GET", false, false); },
|
||||
post() { return dispatch(arguments, "POST", false, false); },
|
||||
put() { return dispatch(arguments, "PUT", false, false); },
|
||||
delete() { return dispatch(arguments, "DELETE", false, false); },
|
||||
patch() { return dispatch(arguments, "PATCH", false, false); },
|
||||
request() { return dispatch(arguments, "GET", false, true); },
|
||||
clearCookies() { return host.clearCookies(); }
|
||||
};
|
||||
globalThis.fetch = function () { return dispatch(arguments, "GET", true, true); };
|
||||
if (host.sessionEnabled) {
|
||||
function invokeSession(method, args) { return JSON.parse(host.sessionCall(method, JSON.stringify(args))); }
|
||||
globalThis.session = {
|
||||
status() { return invokeSession("status", []); },
|
||||
clear() { return invokeSession("clear", []); },
|
||||
completeGrant(grant) { return invokeSession("completeGrant", arguments.length ? [goString(grant)] : []); },
|
||||
signedFetch(method, path, body, headers) {
|
||||
if (arguments.length < 2) return invokeSession("signedFetch", []);
|
||||
try { return invokeSession("signedFetch", [goString(method), goString(path), bodyString(body, false), requestHeaders(headers)]); }
|
||||
catch (error) { return {ok: false, error: goString(error)}; }
|
||||
}
|
||||
};
|
||||
}
|
||||
if (host.authEnabled) {
|
||||
function invokeAuth(method, args, expiresIsFloat = false) {
|
||||
const result = JSON.parse(host.authCall(method, JSON.stringify(args), expiresIsFloat));
|
||||
return method === "getAuthCode" && result === null ? undefined : result;
|
||||
}
|
||||
function goFloat(value) {
|
||||
return typeof value === "number" && (!Number.isInteger(value) || Object.is(value, -0)
|
||||
|| value < -9223372036854775808 || value >= 9223372036854775808);
|
||||
}
|
||||
function authConfig(args) {
|
||||
if (!args.length) return [];
|
||||
if (!isMap(args[0])) return [null];
|
||||
const config = Object.create(null);
|
||||
for (const key of ["authUrl", "clientId", "redirectUri", "scope", "tokenUrl", "code"]) {
|
||||
if (typeof args[0][key] === "string") config[key] = goString(args[0][key]);
|
||||
}
|
||||
if (isMap(args[0].extraParams)) config.extraParams = requestHeaders(args[0].extraParams);
|
||||
return [config];
|
||||
}
|
||||
globalThis.auth = {
|
||||
openAuthUrl(url, callback) {
|
||||
const args = [];
|
||||
if (arguments.length) args.push(goString(url));
|
||||
if (arguments.length > 1 && callback !== undefined) args.push(goString(callback));
|
||||
return invokeAuth("openAuthUrl", args);
|
||||
},
|
||||
getAuthCode() { return invokeAuth("getAuthCode", []); },
|
||||
setAuthCode(value) {
|
||||
if (!arguments.length) return invokeAuth("setAuthCode", []);
|
||||
if (typeof value === "string") return invokeAuth("setAuthCode", [goString(value)]);
|
||||
const config = Object.create(null);
|
||||
if (isMap(value)) {
|
||||
for (const key of ["code", "access_token", "refresh_token"]) {
|
||||
if (typeof value[key] === "string") config[key] = goString(value[key]);
|
||||
}
|
||||
if (typeof value.expires_in === "number") config.expires_in = value.expires_in;
|
||||
}
|
||||
const expiresIsFloat = goFloat(config.expires_in);
|
||||
// JSON cannot carry NaN/Infinity. Go's duration conversion
|
||||
// makes these already expired; retain that outcome.
|
||||
if (expiresIsFloat && !Number.isFinite(config.expires_in)) config.expires_in = 0;
|
||||
return invokeAuth("setAuthCode", [config], expiresIsFloat);
|
||||
},
|
||||
clearAuth() { return invokeAuth("clearAuth", []); },
|
||||
isAuthenticated() { return invokeAuth("isAuthenticated", []); },
|
||||
getTokens() { return invokeAuth("getTokens", []); },
|
||||
generatePKCE(length) {
|
||||
// Goja exports integral JS values as int64; this legacy host
|
||||
// only honors float64 lengths, otherwise it defaults to 64.
|
||||
const size = goFloat(length) && length >= 43 && length <= 128 ? Math.trunc(length) : 64;
|
||||
return invokeAuth("generatePKCE", [size]);
|
||||
},
|
||||
getPKCE() { return invokeAuth("getPKCE", []); },
|
||||
startOAuthWithPKCE() { return invokeAuth("startOAuthWithPKCE", authConfig(arguments)); },
|
||||
exchangeCodeWithPKCE() { return invokeAuth("exchangeCodeWithPKCE", authConfig(arguments)); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function binaryOptions(value) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || ArrayBuffer.isView(value)
|
||||
|| value instanceof ArrayBuffer || value instanceof Date || value instanceof Map || value instanceof Set) return null;
|
||||
const result = Object.create(null);
|
||||
for (const key of Object.keys(value)) {
|
||||
let item = value[key];
|
||||
if (typeof item === "string") item = goString(item);
|
||||
else if (responseByteArrays.has(item)) item = responseByteArrays.get(item);
|
||||
else if (item instanceof Uint8ClampedArray) item = new Uint8Array(item.buffer, item.byteOffset, item.byteLength);
|
||||
if (key === "segments" && Array.isArray(item)) item = Array.from(item, binaryOptions);
|
||||
result[key] = item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function binaryPayload(value) {
|
||||
if (typeof value === "string") return goString(value);
|
||||
if (responseByteArrays.has(value)) return responseByteArrays.get(value);
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (value instanceof Uint8ClampedArray) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
return value;
|
||||
}
|
||||
function blockTransform(operation, args) {
|
||||
if (args.length < 2) return {success: false, error: "data and options are required"};
|
||||
return host.blockTransform(operation, binaryPayload(args[0]), binaryOptions(args[1]));
|
||||
}
|
||||
if (host.filesEnabled) {
|
||||
globalThis.ffmpeg = {
|
||||
getInfo(path) {
|
||||
if (!arguments.length) return {success: false, error: "file path is required"};
|
||||
return host.mediaInfo(goString(path));
|
||||
},
|
||||
convert(input, output, options) {
|
||||
if (arguments.length < 2) return {success: false, error: "input and output paths are required"};
|
||||
return JSON.parse(host.mediaConvert(goString(input), goString(output), binaryOptions(options)));
|
||||
}
|
||||
};
|
||||
if (host.rawFfmpegStub) {
|
||||
globalThis.ffmpeg.execute = function() {
|
||||
return {success: false, error: "raw FFmpeg execution is disabled; use ffmpeg.convert"};
|
||||
};
|
||||
}
|
||||
function fileCall(method, args) {
|
||||
const paired = ["copy", "move", "transformPatternedBlocks"].includes(method);
|
||||
const write = method === "write" || method === "writeBytes";
|
||||
const required = method === "transformPatternedBlocks" ? 3 : paired || write ? 2 : 1;
|
||||
if (args.length < required) {
|
||||
if (method === "exists") return false;
|
||||
return {success: false, error: method === "transformPatternedBlocks"
|
||||
? "input path, output path, and options are required"
|
||||
: paired ? "source and destination paths are required"
|
||||
: write ? "path and data are required" : "path is required"};
|
||||
}
|
||||
const value = paired || method === "write" ? goString(args[1])
|
||||
: method === "writeBytes" ? binaryPayload(args[1]) : undefined;
|
||||
const options = method === "readBytes" ? binaryOptions(args[1])
|
||||
: method === "writeBytes" || method === "transformPatternedBlocks" ? binaryOptions(args[2]) : null;
|
||||
return host.fileCall(method, goString(args[0]), value, options, args[3]);
|
||||
}
|
||||
globalThis.file = {};
|
||||
globalThis.file.download = function(url, path, options) {
|
||||
if (arguments.length < 2) return {success: false, error: "URL and output path are required"};
|
||||
if (!host.networkEnabled) return {success: false, error: "network access unavailable"};
|
||||
const opts = binaryOptions(options);
|
||||
const headers = Object.create(null);
|
||||
if (opts && isMap(opts.headers)) {
|
||||
for (const key of Object.keys(opts.headers)) headers[goString(key)] = formatGo(opts.headers[key]);
|
||||
}
|
||||
return JSON.parse(host.downloadCall(goString(url), goString(path), opts, JSON.stringify(headers)));
|
||||
};
|
||||
globalThis.file.downloadSegments = function(segments, path, options) {
|
||||
const failure = (error_type, error) => ({success: false, error, error_type, attempts: 0});
|
||||
if (arguments.length < 2) return failure("invalid_request", "segments and output path are required");
|
||||
if (!Array.isArray(segments) || !segments.length) return failure("invalid_request", "segments must be a non-empty array");
|
||||
const opts = binaryOptions(options);
|
||||
const common = Object.create(null);
|
||||
function headersInto(target, source) {
|
||||
if (source && typeof source === "object" && !Array.isArray(source)
|
||||
&& !ArrayBuffer.isView(source) && !(source instanceof ArrayBuffer)
|
||||
&& !(source instanceof Date) && !(source instanceof Map) && !(source instanceof Set)) {
|
||||
for (const key of Object.keys(source)) target[goString(key)] = formatGo(source[key]);
|
||||
}
|
||||
}
|
||||
if (host.networkEnabled && opts) headersInto(common, opts.headers);
|
||||
const specs = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const item = segments[index];
|
||||
const headers = Object.assign(Object.create(null), common);
|
||||
let url;
|
||||
if (typeof item === "string") url = item;
|
||||
else if (item && typeof item === "object" && !Array.isArray(item)
|
||||
&& !ArrayBuffer.isView(item) && !(item instanceof ArrayBuffer)
|
||||
&& !(item instanceof Date) && !(item instanceof Map) && !(item instanceof Set)) {
|
||||
url = typeof item.url === "string" ? item.url : "";
|
||||
if (host.networkEnabled) headersInto(headers, item.headers);
|
||||
} else return failure("invalid_request", "segment " + index + " must be a URL string or object");
|
||||
specs.push({url: goString(url), headers});
|
||||
}
|
||||
return JSON.parse(host.downloadSegmentsCall(JSON.stringify(specs), goString(path), opts));
|
||||
};
|
||||
for (const method of ["exists", "delete", "read", "readBytes", "write", "writeBytes", "copy", "move", "getSize", "transformPatternedBlocks"]) {
|
||||
globalThis.file[method] = function() { return fileCall(method, arguments); };
|
||||
}
|
||||
}
|
||||
const logTypedName = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag).get;
|
||||
const logArrayBufferLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength").get;
|
||||
const logDateValue = Date.prototype.getTime;
|
||||
const logMapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size").get;
|
||||
const logSetSize = Object.getOwnPropertyDescriptor(Set.prototype, "size").get;
|
||||
const logBoxValues = [Number.prototype.valueOf, Boolean.prototype.valueOf, BigInt.prototype.valueOf, Symbol.prototype.valueOf];
|
||||
const logApply = Reflect.apply;
|
||||
const logSlice = String.prototype.slice;
|
||||
function logValue(value) {
|
||||
if (value === null || value === undefined) return "<value>";
|
||||
if (byteArrays.has(value)) return "<[]uint8>";
|
||||
const opaque = host.logOpaqueType(value);
|
||||
if (opaque) return opaque;
|
||||
const kind = typeof value;
|
||||
if (kind === "bigint") return "<*big.Int>";
|
||||
if (kind === "function") return "<func(goja.FunctionCall) goja.Value>";
|
||||
if (kind !== "object") return goString(value);
|
||||
if (Array.isArray(value)) return "<[]interface {}>";
|
||||
const typedName = logApply(logTypedName, value, []);
|
||||
if (typedName) {
|
||||
const types = {Uint8Array:"uint8", Uint8ClampedArray:"uint8", Int8Array:"int8", Uint16Array:"uint16", Int16Array:"int16", Uint32Array:"uint32", Int32Array:"int32", Float32Array:"float32", Float64Array:"float64", BigInt64Array:"int64", BigUint64Array:"uint64"};
|
||||
return "<[]" + types[typedName] + ">";
|
||||
}
|
||||
for (const method of logBoxValues) {
|
||||
let primitive;
|
||||
try { primitive = logApply(method, value, []); } catch (_) { continue; }
|
||||
return typeof primitive === "bigint" ? "<*big.Int>" : goString(value);
|
||||
}
|
||||
for (const [method, name] of [[logArrayBufferLength,"goja.ArrayBuffer"], [logDateValue,"time.Time"], [logMapSize,"[][2]interface {}"], [logSetSize,"[]interface {}"]]) {
|
||||
try { logApply(method, value, []); return "<" + name + ">"; } catch (_) {}
|
||||
}
|
||||
return "<map[string]interface {}>";
|
||||
}
|
||||
globalThis.log = {};
|
||||
function logArguments(args) {
|
||||
const values = [];
|
||||
for (let index = 0; index < Math.min(args.length, 8); index++) {
|
||||
// Enough UTF-16 units for Go's byte cut, including overflow detection.
|
||||
values.push(logApply(logSlice, logValue(args[index]), [0, 513]));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
for (const level of ["debug", "info", "warn", "error"]) {
|
||||
globalThis.log[level] = function() {
|
||||
host.extensionLog(level.toUpperCase(), logArguments(arguments), arguments.length);
|
||||
};
|
||||
}
|
||||
if (host.managedConsole) globalThis.console = {log() { host.extensionLog("", logArguments(arguments), arguments.length); }};
|
||||
globalThis.matching = {
|
||||
compareStrings(first, second) {
|
||||
return arguments.length < 2 ? 0 : host.compareStrings(goString(first), goString(second));
|
||||
},
|
||||
compareDuration(first, second, tolerance) {
|
||||
return arguments.length < 2 ? false : host.compareDuration(+first, +second, tolerance === undefined ? 3000 : +tolerance);
|
||||
},
|
||||
normalizeString(value) { return arguments.length ? host.normalizeMatching(goString(value)) : ""; }
|
||||
};
|
||||
if (host.legacyBackend) {
|
||||
const mapEntries = Map.prototype.entries;
|
||||
const setValues = Set.prototype.values;
|
||||
const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size").get;
|
||||
const setSize = Object.getOwnPropertyDescriptor(Set.prototype, "size").get;
|
||||
function filenameMetadata(value) {
|
||||
const seen = new WeakSet();
|
||||
function visit(value, depth) {
|
||||
if (typeof value === "string" || typeof value === "number") return value;
|
||||
if (value === null || typeof value !== "object" || host.logOpaqueType(value)) return null;
|
||||
try { return stringApply(boxedNumberValue, value, []); } catch (_) {}
|
||||
try { stringApply(boxedBooleanValue, value, []); return null; } catch (_) {}
|
||||
if (value instanceof Date || value instanceof ArrayBuffer
|
||||
|| (ArrayBuffer.isView(value) && !(value instanceof DataView))) return null;
|
||||
if (seen.has(value)) return null;
|
||||
if (depth >= 128) throw new TypeError("deeply nested filename metadata");
|
||||
seen.add(value);
|
||||
if (value instanceof Map || value instanceof Set) {
|
||||
const map = value instanceof Map;
|
||||
const count = stringApply(map ? mapSize : setSize, value, []);
|
||||
const iterator = stringApply(map ? mapEntries : setValues, value, []);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const entry = iterator.next();
|
||||
if (entry.done) break;
|
||||
if (map) visit(entry.value[0], depth + 1);
|
||||
visit(map ? entry.value[1] : entry.value, depth + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const length = value.length;
|
||||
for (let index = 0; index < length; index++) visit(value[index], depth + 1);
|
||||
return null;
|
||||
}
|
||||
let stringObject;
|
||||
try { stringObject = stringApply(boxedStringValue, value, []); } catch (_) {}
|
||||
const result = depth === 0 ? Object.create(null) : null;
|
||||
for (const key of Object.keys(value)) {
|
||||
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
|
||||
// Goja exports ignored values too: preserve getter order,
|
||||
// errors and shared/cyclic graph visitation without JSON.
|
||||
const item = visit(value[key], depth + 1);
|
||||
if (result === null) continue;
|
||||
if (typeof item === "string") result[key.toWellFormed()] = item.toWellFormed();
|
||||
else if (typeof item === "number") {
|
||||
result[key.toWellFormed()] = Number.isFinite(item) ? item : Number.isNaN(item) ? 0 : item < 0 ? -1e300 : 1e300;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const result = visit(value, 0);
|
||||
return isMap(result) ? result : null;
|
||||
}
|
||||
globalThis.gobackend = {
|
||||
sanitizeFilename(value) { return arguments.length ? host.legacySanitize(goString(value)) : ""; },
|
||||
buildFilename(template, metadata) {
|
||||
if (arguments.length < 2) return "";
|
||||
const pattern = goString(template);
|
||||
const fields = filenameMetadata(metadata);
|
||||
return fields === null ? "" : host.legacyFilename(pattern, serialize(fields));
|
||||
},
|
||||
getLocalTime() { return JSON.parse(host.legacyLocalTime()); },
|
||||
getAudioQuality(path) {
|
||||
return arguments.length ? JSON.parse(host.legacyQuality(goString(path))) : {error: "file path is required"};
|
||||
},
|
||||
getLyricsLRC(spotifyID, track, artist, path, duration) {
|
||||
if (arguments.length < 3) return {error: "spotifyID, trackName, and artistName are required"};
|
||||
const id = goString(spotifyID), title = goString(track), name = goString(artist);
|
||||
const file = path == null ? "" : goString(path);
|
||||
const value = duration == null ? 0 : goNumberPrimitive(duration);
|
||||
const milliseconds = typeof value === "string" ? 0 : +value;
|
||||
const text = typeof value === "string"
|
||||
? (typeof duration === "string" ? value : value.trim()).toWellFormed() : null;
|
||||
return JSON.parse(host.legacyLyrics(id, title, name, file, milliseconds,
|
||||
text, typeof duration !== "string"));
|
||||
},
|
||||
checkISRCExists(directory, isrc) {
|
||||
return arguments.length < 2 ? {error: "outputDir and isrc are required"}
|
||||
: JSON.parse(host.legacyIsrc(false, goString(directory), goString(isrc), ""));
|
||||
},
|
||||
addToISRCIndex(directory, isrc, path) {
|
||||
return arguments.length < 3 ? {error: "outputDir, isrc, and filePath are required"}
|
||||
: JSON.parse(host.legacyIsrc(true, goString(directory), goString(isrc), goString(path)));
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.utils = {
|
||||
isDownloadCancelled() { return host.downloadCancelled(); },
|
||||
isRequestCancelled() { return host.requestCancelled(); },
|
||||
randomUserAgent() { return host.randomUserAgent(); },
|
||||
appVersion() { return host.appVersion(); },
|
||||
appUserAgent() { return host.appUserAgent(); },
|
||||
setDownloadStatus(status) {
|
||||
if (arguments.length && host.downloadItemActive()) host.downloadStatus(goString(status));
|
||||
},
|
||||
getResolutionRemainingMs() { return host.resolutionRemaining(); },
|
||||
encrypt(data, key) {
|
||||
return arguments.length < 2 ? {success: false, error: "plaintext and key are required"}
|
||||
: host.cryptoText(false, goString(data), goString(key));
|
||||
},
|
||||
decrypt(data, key) {
|
||||
return arguments.length < 2 ? {success: false, error: "ciphertext and key are required"}
|
||||
: host.cryptoText(true, goString(data), goString(key));
|
||||
},
|
||||
generateKey(length) {
|
||||
length = length === undefined ? 32 : Number(length);
|
||||
if (!Number.isInteger(length) || length < 1 || length > 4096)
|
||||
return {success: false, error: "key length must be an integer between 1 and 4096 bytes"};
|
||||
return host.generateKey(length);
|
||||
},
|
||||
encryptBlockCipher() { return blockTransform("encrypt", arguments); },
|
||||
decryptBlockCipher() { return blockTransform("decrypt", arguments); },
|
||||
decryptCTRSegments() { return blockTransform("segments", arguments); },
|
||||
base64Encode(value) { return arguments.length ? host.base64Encode(goString(value)) : ""; },
|
||||
base64Decode(value) { return arguments.length ? host.base64Decode(goString(value), false) : ""; },
|
||||
md5(value) { return arguments.length ? host.md5(goString(value)) : ""; },
|
||||
sha256(value) { return arguments.length ? host.sha256(goString(value)) : ""; },
|
||||
hmacSHA256(message, key) {
|
||||
return arguments.length < 2 ? "" : host.hmacSHA256(goString(message), goString(key));
|
||||
},
|
||||
hmacSHA256Base64(message, key) {
|
||||
return arguments.length < 2 ? "" : host.hmacSHA256Base64(goString(message), goString(key));
|
||||
},
|
||||
hmacSHA1(key, message) {
|
||||
function bytes(value) {
|
||||
if (typeof value === "string") return host.encode(goString(value));
|
||||
if (Array.isArray(value)) return Array.from(value, item => typeof item === "number" ? item & 255 : 0);
|
||||
return null;
|
||||
}
|
||||
key = bytes(key);
|
||||
message = bytes(message);
|
||||
return key === null || message === null ? emptyBytes() : host.hmacSHA1(key, message);
|
||||
},
|
||||
parseJSON(value) {
|
||||
if (!arguments.length) return undefined;
|
||||
try {
|
||||
// encoding/json rejects overflowing numbers and replaces lone surrogates.
|
||||
return JSON.parse(serialize(JSON.parse(String(value))));
|
||||
} catch (_) { return undefined; }
|
||||
},
|
||||
stringifyJSON(value) {
|
||||
if (!arguments.length) return "";
|
||||
try { return serialize(value); } catch (_) { return ""; }
|
||||
},
|
||||
sleep(milliseconds) {
|
||||
if (typeof milliseconds !== "number" || !(milliseconds > 0)) return true;
|
||||
return host.sleep(Math.min(Math.trunc(milliseconds), 300000));
|
||||
}
|
||||
};
|
||||
globalThis.btoa = function (value) {
|
||||
return arguments.length ? host.base64Encode(goString(value)) : "";
|
||||
};
|
||||
globalThis.atob = function (value) {
|
||||
return arguments.length ? host.base64Decode(goString(value), true) : "";
|
||||
};
|
||||
globalThis.TextEncoder = function () {
|
||||
this.encoding = "utf-8";
|
||||
this.encode = function (value) {
|
||||
return arguments.length ? host.encode(goString(value)) : emptyBytes();
|
||||
};
|
||||
this.encodeInto = function (value, destination) {
|
||||
if (arguments.length < 2) return { read: 0, written: 0 };
|
||||
const length = host.encode(goString(value)).length;
|
||||
// Preserve the existing Go host contract, including no destination copy.
|
||||
return { read: length, written: length };
|
||||
};
|
||||
};
|
||||
globalThis.TextDecoder = function (encoding) {
|
||||
this.encoding = encoding === undefined ? "utf-8" : goString(encoding);
|
||||
this.fatal = false;
|
||||
this.ignoreBOM = false;
|
||||
this.decode = function (value) {
|
||||
if (typeof value === "string") return goString(value);
|
||||
if (!Array.isArray(value) && !(value instanceof Uint8Array)) return "";
|
||||
return host.decode(Array.from(value, item => typeof item === "number" ? item & 255 : 0));
|
||||
};
|
||||
};
|
||||
return {
|
||||
providerHelpers: {goString, isMap, exportValue, serialize},
|
||||
registered() { return registered; },
|
||||
getExtension() { return globalThis.extension; },
|
||||
invokeManaged(action, method, args) {
|
||||
const extension = globalThis.extension;
|
||||
let callback;
|
||||
if (extension !== undefined && (action ? extension : true) && typeof extension[method] === "function") {
|
||||
callback = () => extension[method].apply(extension, args);
|
||||
} else if (action && method === "completeGrant" && typeof session !== "undefined" && session && typeof session.completeGrant === "function") {
|
||||
callback = () => session.completeGrant();
|
||||
}
|
||||
if (!callback) return action ? {success:false, error:"Action function not found: " + method} : {success:true, message:"no " + method + " function"};
|
||||
try {
|
||||
const result = callback();
|
||||
if (!action) return {success:true};
|
||||
if (result && typeof result.then === "function") return {success:true, pending:true, message:"Action started"};
|
||||
if (result !== null && result !== undefined && typeof result === "object" && !Array.isArray(result)) {
|
||||
const output = {success:true};
|
||||
for (const key in result) output[key] = result[key];
|
||||
return output;
|
||||
}
|
||||
return {success:true, result};
|
||||
} catch (error) { return {success:false, error:error.toString()}; }
|
||||
},
|
||||
serialize
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
(function (host, helpers) {
|
||||
"use strict";
|
||||
const object = Object;
|
||||
const keys = Object.keys;
|
||||
const isArray = Array.isArray;
|
||||
const apply = Reflect.apply;
|
||||
const stringify = JSON.stringify;
|
||||
const isView = ArrayBuffer.isView;
|
||||
const dateType = Date;
|
||||
const bufferType = ArrayBuffer;
|
||||
const mapType = Map;
|
||||
const setType = Set;
|
||||
const mapEach = Map.prototype.forEach;
|
||||
const setEach = Set.prototype.forEach;
|
||||
const stringValue = String.prototype.valueOf;
|
||||
const numberValue = Number.prototype.valueOf;
|
||||
const booleanValue = Boolean.prototype.valueOf;
|
||||
const symbolValue = Symbol.prototype.valueOf;
|
||||
const {goString, exportValue, serialize} = helpers;
|
||||
const empty = value => value === undefined || value === null;
|
||||
const trim = value => host.providerTrim(value);
|
||||
// Export happens before Go's type assertion. Traverse rejected objects too:
|
||||
// their getters can change later fields. Shared/cyclic objects are visited
|
||||
// once per Export, and unsupported JSON values are harmless if discarded.
|
||||
function exported(value, seen) {
|
||||
if (typeof value === "symbol") return goString(value);
|
||||
if (empty(value) || typeof value !== "object" || host.logOpaqueType(value) !== "") return value;
|
||||
let primitive;
|
||||
try { primitive = apply(numberValue, value, []); } catch (_) {}
|
||||
if (typeof primitive === "number") return primitive;
|
||||
try { primitive = apply(booleanValue, value, []); } catch (_) {}
|
||||
if (typeof primitive === "boolean") return primitive;
|
||||
try { primitive = apply(symbolValue, value, []); } catch (_) {}
|
||||
if (typeof primitive === "symbol") return goString(primitive);
|
||||
if (value instanceof dateType || value instanceof bufferType || isView(value)) return value;
|
||||
if (seen === undefined) seen = new mapType();
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
if (isArray(value) || value instanceof setType) {
|
||||
const result = [];
|
||||
seen.set(value, result);
|
||||
if (isArray(value)) {
|
||||
const length = value.length;
|
||||
for (let index = 0; index < length; index++) result.push(exported(value[index], seen));
|
||||
} else apply(setEach, value, [item => result.push(exported(item, seen))]);
|
||||
return result;
|
||||
}
|
||||
if (value instanceof mapType) {
|
||||
seen.set(value, value);
|
||||
apply(mapEach, value, [(item, key) => { exported(key, seen); exported(item, seen); }]);
|
||||
return value;
|
||||
}
|
||||
let stringObject;
|
||||
try { stringObject = apply(stringValue, value, []); } catch (_) {}
|
||||
const result = object.create(null);
|
||||
seen.set(value, result);
|
||||
for (const key of keys(value)) {
|
||||
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
|
||||
result[key] = exported(value[key], seen);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function first(value, names) {
|
||||
for (const name of names) {
|
||||
const field = value[name];
|
||||
if (!empty(field)) return field;
|
||||
}
|
||||
}
|
||||
function text(value, names) {
|
||||
for (const name of names) {
|
||||
const field = exported(value[name]);
|
||||
if (typeof field === "string") return field.toWellFormed();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function int(value, names, wide = false) {
|
||||
const field = first(value, names);
|
||||
return empty(field) ? 0n : BigInt(host.providerInteger(+field, wide));
|
||||
}
|
||||
function map(value, names) {
|
||||
const field = exported(first(value, names));
|
||||
if (empty(field) || !helpers.isMap(field) || host.logOpaqueType(field) !== "") return null;
|
||||
const result = exportValue(field, []);
|
||||
return result !== null && typeof result === "object" && keys(result).length ? result : null;
|
||||
}
|
||||
function stringMap(value, names) {
|
||||
const field = first(value, names);
|
||||
if (empty(field)) return null;
|
||||
const result = object.create(null);
|
||||
for (const name of keys(object(field))) {
|
||||
const child = field[name];
|
||||
if (!empty(child)) result[name.toWellFormed()] = goString(child);
|
||||
}
|
||||
return keys(result).length ? result : null;
|
||||
}
|
||||
function strings(value, names) {
|
||||
const field = exported(first(value, names));
|
||||
if (!isArray(field) || host.logOpaqueType(field) !== "") return [];
|
||||
const result = [];
|
||||
for (const item of field) {
|
||||
const string = typeof item === "string" ? item.toWellFormed() : "";
|
||||
if (trim(string)) result.push(trim(string));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function fields(value, schema, required) {
|
||||
value = object(value);
|
||||
const result = object.create(null);
|
||||
for (const [name, kind, ...aliases] of schema) {
|
||||
const names = [name, ...aliases];
|
||||
let field;
|
||||
switch (kind) {
|
||||
case "s": field = text(value, names); break;
|
||||
case "i": field = int(value, names); break;
|
||||
case "l": field = int(value, names, true); break;
|
||||
case "b": field = Boolean(first(value, names)); break;
|
||||
case "m": field = map(value, names); break;
|
||||
case "M": field = stringMap(value, names); break;
|
||||
case "a": field = strings(value, names); break;
|
||||
default: throw Error("unknown provider field kind");
|
||||
}
|
||||
if (required.includes(name) || (field !== "" && field !== 0n && field !== false && field !== null && (!isArray(field) || field.length))) result[name] = field;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function array(value, parse) {
|
||||
if (empty(value)) return [];
|
||||
const lengthValue = object(value).length;
|
||||
if (empty(lengthValue)) throw Error("value is not an array");
|
||||
const length = Number(BigInt(host.providerInteger(+lengthValue, true)));
|
||||
if (length <= 0) return [];
|
||||
const count = Number(BigInt(host.providerInteger(length, false)));
|
||||
if (count < 0) throw Error("array length exceeds native integer range");
|
||||
const result = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const item = value[index];
|
||||
if (!empty(item)) result.push(parse(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const trackSchema = [
|
||||
["id","s"],["name","s"],["artists","s"],["album_name","s","albumName"],
|
||||
["album_artist","s","albumArtist"],["album_id","s","albumId"],["album_url","s","albumUrl"],
|
||||
["artist_id","s","artistId"],["artist_url","s","artistUrl"],
|
||||
["external_urls","s","externalUrls","external_url","externalUrl","url"],
|
||||
["duration_ms","i","durationMs"],["cover_url","s","coverUrl"],["preview_url","s","previewUrl"],
|
||||
["images","s"],["release_date","s","releaseDate"],["track_number","i","trackNumber"],
|
||||
["total_tracks","i","totalTracks"],["disc_number","i","discNumber"],["total_discs","i","totalDiscs"],
|
||||
["isrc","s"],["provider_id","s","providerId"],["item_type","s","itemType"],
|
||||
["album_type","s","albumType"],["explicit","b","is_explicit","isExplicit"],["upc","s","barcode"],
|
||||
["tidal_id","s","tidalId"],["qobuz_id","s","qobuzId"],["deezer_id","s","deezerId"],
|
||||
["spotify_id","s","spotifyId"],["external_links","M","externalLinks"],["label","s"],
|
||||
["copyright","s"],["genre","s"],["composer","s"],["comment","s","comments"],
|
||||
["audio_quality","s","audioQuality"],["audio_modes","s","audioModes"]
|
||||
];
|
||||
const track = value => fields(value, trackSchema, ["id","name","artists","album_name","duration_ms","provider_id"]);
|
||||
function album(value) {
|
||||
const tracks = array(first(object(value), ["tracks"]), track);
|
||||
const result = fields(value, [
|
||||
["id","s"],["name","s"],["artists","s"],["artist_id","s","artistId"],
|
||||
["cover_url","s","coverUrl","images"],["header_image","s","headerImage"],
|
||||
["header_video","s","headerVideo"],["release_date","s","releaseDate"],
|
||||
["total_tracks","i","totalTracks"],["album_type","s","albumType"],
|
||||
["audio_traits","a","audioTraits"],["provider_id","s","providerId"]
|
||||
], ["id","name","artists","total_tracks","provider_id"]);
|
||||
result.tracks = tracks;
|
||||
if (!trim(result.artists)) {
|
||||
const explicit = tracks.find(item => trim(item.album_artist || ""));
|
||||
if (explicit) result.artists = trim(explicit.album_artist);
|
||||
else {
|
||||
const counts = new Map();
|
||||
for (const item of tracks) {
|
||||
const artist = trim(item.artists);
|
||||
if (artist) counts.set(artist, (counts.get(artist) || 0) + 1);
|
||||
}
|
||||
let count = 0;
|
||||
result.artists = "";
|
||||
for (const [artist, votes] of counts) if (votes > count) { result.artists = artist; count = votes; }
|
||||
}
|
||||
}
|
||||
if (!trim(result.release_date || "")) {
|
||||
const source = tracks.find(item => trim(item.release_date || ""));
|
||||
if (source) result.release_date = trim(source.release_date);
|
||||
else delete result.release_date;
|
||||
}
|
||||
if (!result.audio_traits) {
|
||||
const traits = host.providerAudioTraits(tracks.map(item => item.audio_quality || ""), tracks.map(item => item.audio_modes || ""));
|
||||
if (traits.length) result.audio_traits = traits;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function artist(value) {
|
||||
const albums = array(first(object(value), ["albums"]), album);
|
||||
const releases = array(first(object(value), ["releases"]), album);
|
||||
const tracks = array(first(object(value), ["top_tracks","topTracks","tracks"]), track);
|
||||
const result = fields(value, [["id","s"],["name","s"],["image_url","s","imageUrl"],
|
||||
["header_image","s","headerImage"],["header_video","s","headerVideo"],
|
||||
["listeners","i"],["provider_id","s","providerId"]], ["id","name","provider_id"]);
|
||||
if (albums.length) result.albums = albums;
|
||||
if (releases.length) result.releases = releases;
|
||||
if (tracks.length) result.top_tracks = tracks;
|
||||
return result;
|
||||
}
|
||||
function decryption(value) {
|
||||
if (empty(value)) return null;
|
||||
const result = fields(value, [["strategy","s"],["key","s"],["iv","s"],
|
||||
["input_format","s","inputFormat"],["output_extension","s","outputExtension"],["options","m"]], []);
|
||||
return keys(result).length ? result : null;
|
||||
}
|
||||
function download(value) {
|
||||
const result = fields(value, [
|
||||
["success","b"],["file_path","s","filePath","path"],["already_exists","b","alreadyExists"],
|
||||
["bit_depth","i","bitDepth"],["sample_rate","i","sampleRate"],["audio_codec","s","audioCodec","codec"],
|
||||
["duration_ms","i","durationMs"],["error_message","s","errorMessage","error"],["error_type","s","errorType"],
|
||||
["retry_after_seconds","i","retryAfterSeconds"],["title","s"],["artist","s"],["album","s"],
|
||||
["album_artist","s","albumArtist"],["track_number","i","trackNumber"],["disc_number","i","discNumber"],
|
||||
["total_tracks","i","totalTracks"],["total_discs","i","totalDiscs"],["release_date","s","releaseDate"],
|
||||
["cover_url","s","coverUrl"],["isrc","s"],["genre","s"],["label","s"],["copyright","s"],
|
||||
["composer","s"],["comment","s","comments"],["explicit","b","is_explicit","isExplicit"],
|
||||
["album_type","s","albumType"],["upc","s","barcode"],["lyrics_lrc","s","lyricsLrc"],
|
||||
["decryption_key","s","decryptionKey"]
|
||||
], ["success"]);
|
||||
const info = decryption(first(object(value), ["decryption"]));
|
||||
if (info) result.decryption = info;
|
||||
object.assign(result, fields(value, [["actual_extension","s","actualExtension"],
|
||||
["output_extension","s","outputExtension"],["actual_container","s","actualContainer","container"],
|
||||
["requires_container_conversion","b","requiresContainerConversion"]], []));
|
||||
return result;
|
||||
}
|
||||
function url(value) {
|
||||
const result = fields(value, [["type","s"],["id","s"],["name","s"],["cover_url","s","coverUrl"],
|
||||
["header_image","s","headerImage"],["header_video","s","headerVideo"]], ["type"]);
|
||||
value = object(value);
|
||||
for (const [name, parse] of [["track",track],["tracks",v => array(v,track)],["album",album],["artist",artist]]) {
|
||||
const field = first(value, [name]);
|
||||
if (!empty(field)) {
|
||||
const parsed = parse(field);
|
||||
if (!isArray(parsed) || parsed.length) result[name] = parsed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function lyrics(value) {
|
||||
const lines = array(first(object(value), ["lines"]), line => fields(line, [
|
||||
["startTimeMs","l","start_time_ms"],["words","s"],["endTimeMs","l","end_time_ms"]
|
||||
], ["startTimeMs","words","endTimeMs"]));
|
||||
const result = fields(value, [["syncType","s","sync_type"],["instrumental","b"],
|
||||
["plainLyrics","s","plain_lyrics"],["provider","s"]], ["syncType","instrumental","plainLyrics","provider"]);
|
||||
result.lines = lines;
|
||||
return result;
|
||||
}
|
||||
function search(value) {
|
||||
const raw = first(object(value), ["tracks"]);
|
||||
const tracks = array(empty(raw) ? value : raw, track);
|
||||
const total = empty(raw) ? BigInt(tracks.length) : int(object(value), ["total"]);
|
||||
return {tracks, total: total || BigInt(tracks.length)};
|
||||
}
|
||||
function encode(value) {
|
||||
// BigInts here are native integer fields created by this parser. Opaque
|
||||
// extension maps pass through the existing Go export conversion first.
|
||||
if (typeof value === "bigint") return String(value);
|
||||
if (isArray(value)) return "[" + value.map(encode).join(",") + "]";
|
||||
if (value !== null && typeof value === "object") return "{" + keys(value).map(key => host.quoteJSON(key) + ":" + encode(value[key])).join(",") + "}";
|
||||
return serialize(value);
|
||||
}
|
||||
const parsers = {
|
||||
getTrack: track, enrichTrack: track, getAlbum: album, getPlaylist: album, getArtist: artist,
|
||||
searchTracks: search, customSearch: value => array(value, track), handleUrl: url,
|
||||
checkAvailability: value => fields(value, [["available","b"],["reason","s"],["track_id","s","trackId"],
|
||||
["skip_fallback","b","skipFallback"],["prepared_context","m","preparedContext"]], ["available"]),
|
||||
download, fetchLyrics: lyrics,
|
||||
postProcessV2: value => fields(value, [["success","b"],["new_file_path","s","newFilePath"],
|
||||
["new_file_uri","s","newFileUri"],["error","s"],["bit_depth","i","bitDepth"],["sample_rate","i","sampleRate"]], ["success"])
|
||||
};
|
||||
function invoke(method, args) {
|
||||
const extension = globalThis.extension;
|
||||
if (empty(extension)) return null;
|
||||
let name = method;
|
||||
if (method === "getPlaylist" && typeof extension.getPlaylist !== "function") name = "getAlbum";
|
||||
if (method === "postProcessV2" && typeof extension.postProcessV2 !== "function") {
|
||||
name = "postProcess";
|
||||
args = [args[0].path || "", args[1], args[2]];
|
||||
}
|
||||
const callback = extension[name];
|
||||
return typeof callback === "function" ? apply(callback, extension, args) : null;
|
||||
}
|
||||
function invokeVerified(method, args) {
|
||||
if (method === "getHomeFeed") {
|
||||
const extension = globalThis.extension;
|
||||
if (!empty(extension) && typeof extension[method] === "function") {
|
||||
return apply(extension[method], extension, args);
|
||||
}
|
||||
const callback = globalThis[method];
|
||||
return typeof callback === "function" ? apply(callback, globalThis, args) : null;
|
||||
}
|
||||
try { return invoke(method, args); }
|
||||
catch (error) {
|
||||
const id = method === "enrichTrack" ? null : host.providerPendingVerification();
|
||||
if (!empty(id) && !empty(error)) {
|
||||
let message;
|
||||
try {
|
||||
const value = typeof error === "object" || typeof error === "function" ? error.message : error;
|
||||
if (!empty(value)) message = trim(goString(value));
|
||||
} catch (_) {}
|
||||
if (message === "VERIFY_REQUIRED") {
|
||||
throw "verification_required: extension '" + id + "' needs signed-session verification: " + goString(error);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {
|
||||
invoke: invokeVerified,
|
||||
invokeDownload(method, args) {
|
||||
const extension = globalThis.extension;
|
||||
if (empty(extension)) return null;
|
||||
const callback = extension[method];
|
||||
if (typeof callback !== "function") return null;
|
||||
const progress = function (value) {
|
||||
if (!arguments.length) return;
|
||||
const percent = BigInt(host.providerInteger(+value, false));
|
||||
host.providerProgress(Number(percent < 0n ? 0n : percent > 100n ? 100n : percent));
|
||||
};
|
||||
return apply(callback, extension, [args[0], args[1], args[2], progress, args[3]]);
|
||||
},
|
||||
invokePostProcess(method, args) {
|
||||
const result = {};
|
||||
try { result.value = invokeVerified(method, args); }
|
||||
catch (error) { result.failure = goString(error); }
|
||||
return result;
|
||||
},
|
||||
parsePostProcess(method, result) {
|
||||
let value;
|
||||
try {
|
||||
value = result.failure !== undefined ? {success:false,error:result.failure}
|
||||
: empty(result.value) ? {success:false,error:"postProcess returned null"}
|
||||
: parsers.postProcessV2(result.value);
|
||||
} catch (error) { value = {success:false,error:error.message || goString(error)}; }
|
||||
return "{\"value\":" + encode(value) + "}";
|
||||
},
|
||||
parse(method, value) {
|
||||
if (empty(value)) return "{\"value\":null}";
|
||||
if (method === "getHomeFeed") {
|
||||
// The Go export marshals goja.Value directly: JSON.stringify,
|
||||
// including toJSON/undefined semantics, without awaiting Promises.
|
||||
try { return "{\"value\":" + (stringify(value) || "null") + "}"; }
|
||||
catch (error) { return serialize({parseError: error.message || goString(error)}); }
|
||||
}
|
||||
try { return "{\"value\":" + encode(parsers[method](value)) + "}"; }
|
||||
catch (error) { return serialize({parseError: error.message || goString(error)}); }
|
||||
}
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Native numeric and album-quality rules used while reading provider objects.
|
||||
|
||||
pub(crate) fn integer(value: f64, wide: bool) -> String {
|
||||
let integer = value as i64;
|
||||
if wide {
|
||||
integer.to_string()
|
||||
} else {
|
||||
(integer as isize).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn audio_traits(qualities: Vec<String>, modes: Vec<String>) -> Vec<String> {
|
||||
let mut atmos = false;
|
||||
let mut hires = false;
|
||||
let mut lossless = false;
|
||||
for (quality, mode) in qualities.iter().zip(&modes) {
|
||||
// Go uses one-rune uppercase mappings, so expanding ß to SS must not
|
||||
// accidentally make an unrecognized label match LOSSLESS.
|
||||
let upper = |value: &str| {
|
||||
value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
let mut mapped = character.to_uppercase();
|
||||
let first = mapped.next().unwrap();
|
||||
if mapped.next().is_none() {
|
||||
first
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
};
|
||||
let quality = upper(quality);
|
||||
atmos |= upper(mode).contains("ATMOS") || quality.contains("ATMOS");
|
||||
hires |= ["HI_RES", "HIRES", "MASTER", "MQA"]
|
||||
.iter()
|
||||
.any(|part| quality.contains(part));
|
||||
lossless |= quality.contains("LOSSLESS") || quality.contains("FLAC");
|
||||
let lower = spotiflac_core::matching::lowercase(&quality);
|
||||
let numeric_prefix = |suffix: &str, decimal: bool| {
|
||||
lower
|
||||
.find(suffix)
|
||||
.map(|end| {
|
||||
let mut start = end;
|
||||
while start > 0
|
||||
&& (lower.as_bytes()[start - 1].is_ascii_digit()
|
||||
|| (decimal && lower.as_bytes()[start - 1] == b'.'))
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
&lower[start..end]
|
||||
})
|
||||
.unwrap_or("")
|
||||
};
|
||||
let depth = numeric_prefix("bit", false).parse::<isize>().unwrap_or(0);
|
||||
let rate = numeric_prefix("khz", true)
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|value| value.is_finite())
|
||||
.unwrap_or(0.0);
|
||||
if depth > 0 {
|
||||
if depth > 16 || rate > 48.0 {
|
||||
hires = true;
|
||||
} else {
|
||||
lossless = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut traits = Vec::new();
|
||||
if atmos {
|
||||
traits.push("dolby_atmos".into());
|
||||
}
|
||||
if hires {
|
||||
traits.push("hi_res_lossless".into());
|
||||
} else if lossless {
|
||||
traits.push("lossless".into());
|
||||
}
|
||||
traits
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
|
||||
// Go's RE2 uses ASCII whitespace and word boundaries. Keep Unicode token
|
||||
// contents inside the redacted match instead of treating them as delimiters.
|
||||
vec![
|
||||
(r"(?i)(?-u:\b)Authorization(?-u:\b)[\t\n\f\r ]*[:=][\t\n\f\r ]*Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Authorization: Bearer [REDACTED]"),
|
||||
(r#"(?i)("?(?:access[_\t\n\f\r -]?token|refresh[_\t\n\f\r -]?token|id[_\t\n\f\r -]?token|client[_\t\n\f\r -]?secret|authorization|password|api[_\t\n\f\r -]?key|session[_\t\n\f\r -]?secret|decryption[_\t\n\f\r -]?key|cookie|set-cookie)"?)([\t\n\f\r ]*[:=][\t\n\f\r ]*)("(?:\\.|[^"\\])*"|[^\t\n\f\r ,;}\]]+)"#, "${1}${2}[REDACTED]"),
|
||||
(r"(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password|code|grant|sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|awsaccesskeyid|googleaccessid|policy|key-pair-id)=)[^&\t\n\f\r ]+", "${1}[REDACTED]"),
|
||||
(r"(?i)(?-u:\b)Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Bearer [REDACTED]"),
|
||||
(r"(?i)(-decryption_key[\t\n\f\r ]+)[^\t\n\f\r ]+", "${1}[REDACTED]"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, replacement)| (Regex::new(pattern).expect("static redaction pattern"), replacement))
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn sanitize_text(mut text: String) -> String {
|
||||
for (pattern, replacement) in PATTERNS.iter() {
|
||||
if let Cow::Owned(replaced) = pattern.replace_all(&text, *replacement) {
|
||||
text = replaced;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub(crate) fn preview(bytes: &[u8], limit: usize) -> String {
|
||||
let mut text = sanitize_text(crate::host::decode_go_utf8(bytes));
|
||||
if text.len() > limit {
|
||||
text = crate::host::decode_go_utf8(&text.as_bytes()[..limit]) + "...[truncated]";
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Log arguments can end mid-codepoint after Go's 512-byte truncation. Preserve
|
||||
/// their raw bytes while matching as Go's regexp UTF-8 decoder would; converting
|
||||
/// them to replacement characters first would change the later 4000-byte cut.
|
||||
pub(crate) fn sanitize_bytes(bytes: Vec<u8>) -> Vec<u8> {
|
||||
let mut bytes = match String::from_utf8(bytes) {
|
||||
Ok(text) => return sanitize_text(text).into_bytes(),
|
||||
Err(error) => error.into_bytes(),
|
||||
};
|
||||
for (pattern, replacement) in PATTERNS.iter() {
|
||||
let (text, invalid) = decoded_positions(&bytes);
|
||||
let position = |index: usize| index - 2 * invalid.partition_point(|start| *start < index);
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut copied = 0;
|
||||
for captures in pattern.captures_iter(&text) {
|
||||
let found = captures.get(0).expect("redaction match");
|
||||
let start = position(found.start());
|
||||
let end = position(found.end());
|
||||
output.extend_from_slice(&bytes[copied..start]);
|
||||
let mut expanded = String::new();
|
||||
// Retained captures are only key names/separators, never secret
|
||||
// values; they cannot contain an invalid UTF-8 byte.
|
||||
captures.expand(replacement, &mut expanded);
|
||||
output.extend_from_slice(expanded.as_bytes());
|
||||
copied = end;
|
||||
}
|
||||
output.extend_from_slice(&bytes[copied..]);
|
||||
bytes = output;
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn decoded_positions(mut bytes: &[u8]) -> (String, Vec<usize>) {
|
||||
let mut text = String::with_capacity(bytes.len());
|
||||
let mut invalid = Vec::new();
|
||||
while !bytes.is_empty() {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(valid) => {
|
||||
text.push_str(valid);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let prefix = error.valid_up_to();
|
||||
text.push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix"));
|
||||
invalid.push(text.len());
|
||||
text.push('\u{fffd}');
|
||||
bytes = &bytes[prefix + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
(text, invalid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redaction_preserves_unicode_and_invalid_bytes_around_secret_values() {
|
||||
for prefix in [b"track: ".as_slice(), "曲名: ".as_bytes(), b"\xff\xe3\x81 "] {
|
||||
let input = [prefix, b"https://example.test/audio?token=fixture-secret&track=7 password=fixture-password;"].concat();
|
||||
let expected = [
|
||||
prefix,
|
||||
b"https://example.test/audio?token=[REDACTED]&track=7 password=[REDACTED];",
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(sanitize_bytes(input.clone()), expected);
|
||||
assert_eq!(
|
||||
preview(&input, usize::MAX),
|
||||
crate::host::decode_go_utf8(&expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Native-owned registry cache and verified package downloads.
|
||||
|
||||
mod model;
|
||||
mod package;
|
||||
mod urls;
|
||||
|
||||
pub use model::{Registry, RepoExtension, normalize_sha256};
|
||||
pub use package::{destination_path, write_verified_package};
|
||||
pub use urls::{RegistryLocation, registry_location, require_https};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spotiflac_network::{HttpRequest, NetworkService, NetworkSession};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const MAX_REGISTRY_BYTES: usize = 4 << 20;
|
||||
const MAX_CACHE_BYTES: u64 = 8 << 20;
|
||||
const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const CACHE_FILE: &str = "store_cache.json";
|
||||
const REGISTRY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct RepositoryError(pub String);
|
||||
|
||||
fn error(message: impl Into<String>) -> RepositoryError {
|
||||
RepositoryError(message.into())
|
||||
}
|
||||
fn cause(context: &str, error: impl std::fmt::Display) -> RepositoryError {
|
||||
RepositoryError(format!("{context}: {error}"))
|
||||
}
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
struct Cache {
|
||||
registry_url: String,
|
||||
#[serde(
|
||||
serialize_with = "serialize_registry",
|
||||
deserialize_with = "deserialize_registry"
|
||||
)]
|
||||
registry: Arc<Registry>,
|
||||
cache_time: i64,
|
||||
etag: String,
|
||||
last_modified: String,
|
||||
}
|
||||
|
||||
fn serialize_registry<S>(registry: &Arc<Registry>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
registry.as_ref().serialize(serializer)
|
||||
}
|
||||
|
||||
fn deserialize_registry<'de, D>(deserializer: D) -> Result<Arc<Registry>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Registry::deserialize(deserializer).map(Arc::new)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Flight {
|
||||
result: Mutex<Option<Result<Arc<Registry>, RepositoryError>>>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
url: String,
|
||||
generation: u64,
|
||||
cache: Option<Arc<Cache>>,
|
||||
flights: BTreeMap<u64, Arc<Flight>>,
|
||||
}
|
||||
|
||||
pub struct ExtensionRepository {
|
||||
network: Arc<NetworkService>,
|
||||
cache_directory: PathBuf,
|
||||
_directory_lock: File,
|
||||
state: Mutex<State>,
|
||||
pending: Mutex<usize>,
|
||||
idle: Condvar,
|
||||
closed: AtomicBool,
|
||||
parent_closed: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ExtensionRepository {
|
||||
/// The cache directory belongs to this native object, never to JavaScript.
|
||||
pub fn with_network(
|
||||
directory: &Path,
|
||||
network: Arc<NetworkService>,
|
||||
) -> Result<Self, RepositoryError> {
|
||||
if directory.as_os_str().is_empty() {
|
||||
return Err(error("repository cache directory is not configured"));
|
||||
}
|
||||
fs::create_dir_all(directory).map_err(|e| cause("failed to create repository cache", e))?;
|
||||
let cache_directory = fs::canonicalize(directory)
|
||||
.map_err(|e| cause("failed to inspect repository cache", e))?;
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(
|
||||
(rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32,
|
||||
)
|
||||
.open(cache_directory.join(".backend-repository.lock"))
|
||||
.map_err(|e| cause("failed to lock repository cache", e))?;
|
||||
if !lock
|
||||
.metadata()
|
||||
.map_err(|e| cause("failed to inspect repository lock", e))?
|
||||
.is_file()
|
||||
{
|
||||
return Err(error("repository cache lock must be a regular file"));
|
||||
}
|
||||
lock.try_lock()
|
||||
.map_err(|_| error("repository cache is already managed"))?;
|
||||
let cache = (|| {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(
|
||||
(rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32,
|
||||
)
|
||||
.open(cache_directory.join(CACHE_FILE))
|
||||
.ok()?;
|
||||
let metadata = file.metadata().ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_CACHE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_CACHE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
if bytes.len() as u64 > MAX_CACHE_BYTES {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice::<Cache>(&bytes).ok().map(Arc::new)
|
||||
})();
|
||||
Ok(Self {
|
||||
network,
|
||||
cache_directory,
|
||||
_directory_lock: lock,
|
||||
state: Mutex::new(State {
|
||||
url: cache
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |cache| cache.registry_url.clone()),
|
||||
cache,
|
||||
..State::default()
|
||||
}),
|
||||
pending: Mutex::new(0),
|
||||
idle: Condvar::new(),
|
||||
closed: AtomicBool::new(false),
|
||||
parent_closed: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn attach_environment(mut self, closed: Arc<AtomicBool>) -> Self {
|
||||
self.parent_closed = Some(closed);
|
||||
self
|
||||
}
|
||||
|
||||
fn enter(&self) -> Result<Operation<'_>, RepositoryError> {
|
||||
let mut pending = self.pending.lock().expect("repository operations lock");
|
||||
self.check()?;
|
||||
*pending += 1;
|
||||
Ok(Operation(self))
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), RepositoryError> {
|
||||
if self.closed.load(Ordering::Acquire)
|
||||
|| self
|
||||
.parent_closed
|
||||
.as_ref()
|
||||
.is_some_and(|closed| closed.load(Ordering::Acquire))
|
||||
{
|
||||
Err(error("extension repository closed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_generation(&self, generation: u64) -> Result<(), RepositoryError> {
|
||||
self.check()?;
|
||||
if self.state.lock().expect("repository state lock").generation != generation {
|
||||
Err(error("registry changed while operation was running"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registry_url(&self) -> Result<String, RepositoryError> {
|
||||
self.check()?;
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.expect("repository state lock")
|
||||
.url
|
||||
.clone())
|
||||
}
|
||||
|
||||
pub fn set_registry_url(&self, input: &str) -> Result<(), RepositoryError> {
|
||||
let _operation = self.enter()?;
|
||||
let url = match registry_location(input)? {
|
||||
RegistryLocation::Direct(url) => url,
|
||||
RegistryLocation::GitHub { owner, repository } => {
|
||||
let api = format!("https://api.github.com/repos/{owner}/{repository}");
|
||||
let session = self.network.native_session(Duration::from_secs(10));
|
||||
let response = session.request(request(&api, BTreeMap::new()), || {
|
||||
self.check().map_err(|e| e.to_string())
|
||||
});
|
||||
self.check()?;
|
||||
let branch = response
|
||||
.ok()
|
||||
.filter(|response| response.status == 200)
|
||||
.and_then(|response| {
|
||||
serde_json::from_slice::<serde_json::Value>(&response.body).ok()
|
||||
})
|
||||
.and_then(|value| {
|
||||
value["default_branch"]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "main".into());
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{owner}/{repository}/{branch}/registry.json"
|
||||
)
|
||||
}
|
||||
};
|
||||
require_https(&url, "registry")?;
|
||||
let mut state = self.state.lock().expect("repository state lock");
|
||||
self.check()?;
|
||||
if state.url != url {
|
||||
state.url = url;
|
||||
self.clear_locked(&mut state);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_registry_url(&self) -> Result<(), RepositoryError> {
|
||||
self.check()?;
|
||||
let mut state = self.state.lock().expect("repository state lock");
|
||||
state.url.clear();
|
||||
self.clear_locked(&mut state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) -> Result<(), RepositoryError> {
|
||||
self.check()?;
|
||||
self.clear_locked(&mut self.state.lock().expect("repository state lock"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_locked(&self, state: &mut State) {
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
state.cache = None;
|
||||
state.flights.clear();
|
||||
let _ = fs::remove_file(self.cache_directory.join(CACHE_FILE));
|
||||
}
|
||||
|
||||
fn save_locked(&self, cache: &Cache) {
|
||||
// Hold the state lock through publication: a URL change/clear cannot
|
||||
// race an old snapshot back onto disk. A cache write failure is benign.
|
||||
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let bytes = serde_json::to_vec(cache)?;
|
||||
if bytes.len() as u64 > MAX_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = tempfile::NamedTempFile::new_in(&self.cache_directory)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(self.cache_directory.join(CACHE_FILE))?;
|
||||
Ok(())
|
||||
})();
|
||||
}
|
||||
|
||||
pub fn fetch(&self, force: bool) -> Result<Arc<Registry>, RepositoryError> {
|
||||
let _operation = self.enter()?;
|
||||
let (url, generation, cached, flight, leader) = {
|
||||
let mut state = self.state.lock().expect("repository state lock");
|
||||
if state.url.is_empty() {
|
||||
return Err(error(
|
||||
"no registry URL configured. Please add a repository URL first",
|
||||
));
|
||||
}
|
||||
require_https(&state.url, "registry")?;
|
||||
if !force
|
||||
&& let Some(cache) = &state.cache
|
||||
&& now().saturating_sub(cache.cache_time) < CACHE_TTL.as_secs() as i64
|
||||
{
|
||||
return Ok(Arc::clone(&cache.registry));
|
||||
}
|
||||
let generation = state.generation;
|
||||
let leader = !state.flights.contains_key(&generation);
|
||||
let flight = state.flights.entry(generation).or_default().clone();
|
||||
(
|
||||
state.url.clone(),
|
||||
generation,
|
||||
state.cache.clone(),
|
||||
flight,
|
||||
leader,
|
||||
)
|
||||
};
|
||||
if !leader {
|
||||
let mut result = flight.result.lock().expect("repository refresh lock");
|
||||
loop {
|
||||
self.check_generation(generation)?;
|
||||
if let Some(result) = &*result {
|
||||
return result.clone();
|
||||
}
|
||||
result = flight
|
||||
.ready
|
||||
.wait_timeout(result, Duration::from_millis(10))
|
||||
.expect("repository refresh wait")
|
||||
.0;
|
||||
}
|
||||
}
|
||||
let result = self.refresh(&url, generation, cached);
|
||||
*flight.result.lock().expect("repository refresh lock") = Some(result.clone());
|
||||
flight.ready.notify_all();
|
||||
self.state
|
||||
.lock()
|
||||
.expect("repository state lock")
|
||||
.flights
|
||||
.remove(&generation);
|
||||
result
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
&self,
|
||||
url: &str,
|
||||
generation: u64,
|
||||
cached: Option<Arc<Cache>>,
|
||||
) -> Result<Arc<Registry>, RepositoryError> {
|
||||
let check = || self.check_generation(generation).map_err(|e| e.to_string());
|
||||
let session = self.network.native_session(REGISTRY_TIMEOUT);
|
||||
let mut headers = BTreeMap::new();
|
||||
if let Some(cache) = &cached {
|
||||
if !cache.etag.is_empty() {
|
||||
headers.insert("If-None-Match".into(), cache.etag.clone());
|
||||
}
|
||||
if !cache.last_modified.is_empty() {
|
||||
headers.insert("If-Modified-Since".into(), cache.last_modified.clone());
|
||||
}
|
||||
}
|
||||
let response = read_registry(&session, request(url, headers), &check);
|
||||
self.check_generation(generation)?;
|
||||
let (status, headers, body) = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) if error.0.starts_with("failed to read registry:") => return Err(error),
|
||||
Err(error) => {
|
||||
return cached.map(|cache| Arc::clone(&cache.registry)).ok_or(error);
|
||||
}
|
||||
};
|
||||
if status != 200 && !(status == 304 && cached.is_some()) {
|
||||
return cached
|
||||
.map(|cache| Arc::clone(&cache.registry))
|
||||
.ok_or_else(|| error(format!("registry returned HTTP {status}")));
|
||||
}
|
||||
let cache = if status == 304 {
|
||||
let mut cache = (*cached.expect("304 cache")).clone();
|
||||
cache.cache_time = now();
|
||||
cache
|
||||
} else {
|
||||
let registry = match Registry::parse(&body) {
|
||||
Ok(registry) => registry,
|
||||
Err(error) => {
|
||||
return cached.map(|cache| Arc::clone(&cache.registry)).ok_or(error);
|
||||
}
|
||||
};
|
||||
let header = |name| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|values| values.first())
|
||||
.map_or("", |value| value.trim())
|
||||
.to_owned()
|
||||
};
|
||||
Cache {
|
||||
registry_url: url.into(),
|
||||
registry: Arc::new(registry),
|
||||
cache_time: now(),
|
||||
etag: header("Etag"),
|
||||
last_modified: header("Last-Modified"),
|
||||
}
|
||||
};
|
||||
let registry = Arc::clone(&cache.registry);
|
||||
let mut state = self.state.lock().expect("repository state lock");
|
||||
self.check()?;
|
||||
if state.generation != generation {
|
||||
return Err(error("registry changed while operation was running"));
|
||||
}
|
||||
self.save_locked(&cache);
|
||||
state.cache = Some(Arc::new(cache));
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn extensions(
|
||||
&self,
|
||||
force: bool,
|
||||
installed: &BTreeMap<String, String>,
|
||||
query: &str,
|
||||
category: &str,
|
||||
) -> Result<String, RepositoryError> {
|
||||
Ok(
|
||||
serde_json::to_string(&self.fetch(force)?.responses(installed, query, category))
|
||||
.expect("registry JSON"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn categories(&self) -> Result<Vec<String>, RepositoryError> {
|
||||
self.check()?;
|
||||
Ok(["metadata", "download", "utility", "lyrics", "integration"]
|
||||
.map(str::to_owned)
|
||||
.to_vec())
|
||||
}
|
||||
|
||||
pub fn download(&self, id: &str, directory: &Path) -> Result<PathBuf, RepositoryError> {
|
||||
let _operation = self.enter()?;
|
||||
let generation = self.state.lock().expect("repository state lock").generation;
|
||||
let registry = self.fetch(false)?;
|
||||
let extension = registry
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.ok_or_else(|| error(format!("extension {id} not found in repo")))?;
|
||||
require_https(extension.download_url(), "extension download")?;
|
||||
let destination = destination_path(directory, id, extension.download_url())?;
|
||||
let check = || self.check_generation(generation).map_err(|e| e.to_string());
|
||||
let session = self.network.native_session(DOWNLOAD_TIMEOUT);
|
||||
let headers = [
|
||||
("Cache-Control".into(), "no-cache".into()),
|
||||
("Pragma".into(), "no-cache".into()),
|
||||
]
|
||||
.into();
|
||||
let mut stream = session
|
||||
.open_stream(
|
||||
request(extension.download_url(), headers),
|
||||
DOWNLOAD_TIMEOUT,
|
||||
DOWNLOAD_TIMEOUT,
|
||||
check,
|
||||
)
|
||||
.map_err(|e| cause("failed to download", e))?;
|
||||
if stream.response.status != 200 {
|
||||
return Err(error(format!(
|
||||
"download returned HTTP {}",
|
||||
stream.response.status
|
||||
)));
|
||||
}
|
||||
write_verified_package(
|
||||
|buffer| stream.read(buffer, check),
|
||||
&destination,
|
||||
extension.raw_sha256(),
|
||||
&check,
|
||||
)?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
let pending = self.pending.lock().expect("repository operations lock");
|
||||
drop(
|
||||
self.idle
|
||||
.wait_while(pending, |pending| *pending > 0)
|
||||
.expect("repository operations wait"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct Operation<'a>(&'a ExtensionRepository);
|
||||
|
||||
impl Drop for Operation<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut pending = self.0.pending.lock().expect("repository operations lock");
|
||||
*pending -= 1;
|
||||
self.0.idle.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn request(url: &str, headers: BTreeMap<String, String>) -> HttpRequest {
|
||||
HttpRequest {
|
||||
url: url.into(),
|
||||
method: "GET".into(),
|
||||
body: String::new(),
|
||||
headers,
|
||||
default_json: false,
|
||||
user_agent: "Go-http-client/1.1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
type RegistryResponse = (u16, BTreeMap<String, Vec<String>>, Vec<u8>);
|
||||
|
||||
fn read_registry(
|
||||
session: &NetworkSession,
|
||||
request: HttpRequest,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<RegistryResponse, RepositoryError> {
|
||||
let mut stream = session
|
||||
.open_stream(request, REGISTRY_TIMEOUT, REGISTRY_TIMEOUT, check)
|
||||
.map_err(|e| cause("failed to fetch registry", e))?;
|
||||
let status = stream.response.status;
|
||||
let mut body = Vec::new();
|
||||
if status == 200 {
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let limit = buffer.len().min(MAX_REGISTRY_BYTES + 1 - body.len());
|
||||
let count = stream
|
||||
.read(&mut buffer[..limit], check)
|
||||
.map_err(|e| cause("failed to read registry", e))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&buffer[..count]);
|
||||
if body.len() > MAX_REGISTRY_BYTES {
|
||||
return Err(error(format!(
|
||||
"registry response exceeds {MAX_REGISTRY_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((status, stream.response.headers, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cache_registry_clone_shares_arc_and_round_trips_disk_shape() {
|
||||
let bytes = br#"{"registry_url":"https://example.test/registry.json","registry":{"version":1,"updated_at":"2026-09-14","extensions":[]},"cache_time":123,"etag":"etag","last_modified":"Mon, 14 Sep 2026 00:00:00 GMT"}"#;
|
||||
let cache: Cache = serde_json::from_slice(bytes).unwrap();
|
||||
let clone = cache.clone();
|
||||
|
||||
assert!(Arc::ptr_eq(&cache.registry, &clone.registry));
|
||||
assert_eq!(serde_json::to_vec(&cache).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::{RepositoryError, error};
|
||||
use crate::environment::compare_versions;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Preserve Go's case-insensitive fields, duplicate-field order and null rules.
|
||||
// A null scalar leaves its previous value; a null slice clears the slice.
|
||||
trait NullField {
|
||||
fn clear_null(&mut self) {}
|
||||
}
|
||||
impl NullField for String {}
|
||||
impl NullField for isize {}
|
||||
impl<T> NullField for Vec<T> {
|
||||
fn clear_null(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Tags(Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for Tags {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Ok(Self(
|
||||
Vec::<Option<String>>::deserialize(deserializer)?
|
||||
.into_iter()
|
||||
.map(Option::unwrap_or_default)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Tags {
|
||||
type Target = Vec<String>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl NullField for Tags {
|
||||
fn clear_null(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn field_matches(key: &str, wire: &str) -> bool {
|
||||
key.chars()
|
||||
.map(|value| match value {
|
||||
'ſ' => 's',
|
||||
'K' => 'k',
|
||||
value => value.to_ascii_lowercase(),
|
||||
})
|
||||
.eq(wire.chars().map(|value| value.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
macro_rules! wire_struct {
|
||||
($name:ident {$($field:ident: $kind:ty => $wire:literal),* $(,)?}) => {
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct $name {
|
||||
$(#[serde(rename = $wire)] pub $field: $kind,)*
|
||||
}
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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 registry object")
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok($name::default())
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut result = $name::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
$(if field_matches(&key, $wire) {
|
||||
match map.next_value::<Option<$kind>>()? {
|
||||
Some(value) => result.$field = value,
|
||||
None => result.$field.clear_null(),
|
||||
}
|
||||
continue;
|
||||
})*
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wire_struct!(Registry {
|
||||
version: isize => "version",
|
||||
updated_at: String => "updated_at",
|
||||
extensions: Vec<RepoExtension> => "extensions",
|
||||
});
|
||||
|
||||
wire_struct!(RepoExtension {
|
||||
id: String => "id",
|
||||
name: String => "name",
|
||||
display_name: String => "display_name",
|
||||
version: String => "version",
|
||||
description: String => "description",
|
||||
download_url: String => "download_url",
|
||||
icon_url: String => "icon_url",
|
||||
category: String => "category",
|
||||
tags: Tags => "tags",
|
||||
downloads: isize => "downloads",
|
||||
updated_at: String => "updated_at",
|
||||
min_app_version: String => "min_app_version",
|
||||
sha256: String => "sha256",
|
||||
checksum_sha256: String => "checksum_sha256",
|
||||
display_name_alt: String => "displayName",
|
||||
download_url_alt: String => "downloadUrl",
|
||||
icon_url_alt: String => "iconUrl",
|
||||
min_app_version_alt: String => "minAppVersion",
|
||||
checksum_alt: String => "checksumSha256",
|
||||
});
|
||||
|
||||
fn first<'a>(values: &[&'a str]) -> &'a str {
|
||||
values
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn normalize_sha256(value: &str) -> String {
|
||||
let normalized = lowercase(value.trim());
|
||||
let normalized = normalized.strip_prefix("sha256:").unwrap_or(&normalized);
|
||||
if normalized.len() == 64 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
normalized.into()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoExtension {
|
||||
pub fn raw_sha256(&self) -> &str {
|
||||
first(&[
|
||||
self.sha256.trim(),
|
||||
self.checksum_sha256.trim(),
|
||||
self.checksum_alt.trim(),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn download_url(&self) -> &str {
|
||||
first(&[&self.download_url, &self.download_url_alt])
|
||||
}
|
||||
|
||||
pub fn response(&self, installed: &BTreeMap<String, String>) -> Value {
|
||||
let version = installed.get(&self.id);
|
||||
let mut result = json!({
|
||||
"id":self.id,"name":self.name,"version":self.version,
|
||||
"display_name":first(&[&self.display_name,&self.display_name_alt,&self.name]),
|
||||
"description":self.description,"download_url":self.download_url(),
|
||||
"category":self.category,"downloads":self.downloads,"updated_at":self.updated_at,
|
||||
"is_installed":version.is_some(),
|
||||
"has_update":version.is_some_and(|installed| compare_versions(&self.version,installed)==Ordering::Greater),
|
||||
});
|
||||
for (key, value) in [
|
||||
("icon_url", first(&[&self.icon_url, &self.icon_url_alt])),
|
||||
(
|
||||
"min_app_version",
|
||||
first(&[&self.min_app_version, &self.min_app_version_alt]),
|
||||
),
|
||||
("installed_version", version.map_or("", String::as_str)),
|
||||
("sha256", normalize_sha256(self.raw_sha256()).as_str()),
|
||||
] {
|
||||
if !value.is_empty() {
|
||||
result[key] = value.into();
|
||||
}
|
||||
}
|
||||
if !self.tags.is_empty() {
|
||||
result["tags"] = json!(self.tags);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn parse(body: &[u8]) -> Result<Self, RepositoryError> {
|
||||
let text = crate::host::decode_go_utf8(body);
|
||||
let mut registry: Self = serde_json::from_str(&text).map_err(|cause| {
|
||||
if text.trim_start().starts_with('<') {
|
||||
error("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
|
||||
} else {
|
||||
error(format!("failed to parse registry: {cause}"))
|
||||
}
|
||||
})?;
|
||||
registry.extensions.retain(|entry| {
|
||||
entry.raw_sha256().is_empty() || !normalize_sha256(entry.raw_sha256()).is_empty()
|
||||
});
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn responses(
|
||||
&self,
|
||||
installed: &BTreeMap<String, String>,
|
||||
query: &str,
|
||||
category: &str,
|
||||
) -> Vec<Value> {
|
||||
let query = lowercase(query);
|
||||
self.extensions
|
||||
.iter()
|
||||
.filter(|entry| category.is_empty() || entry.category == category)
|
||||
.map(|entry| entry.response(installed))
|
||||
.filter(|entry| {
|
||||
query.is_empty()
|
||||
|| ["name", "display_name", "description"]
|
||||
.iter()
|
||||
.any(|key| lowercase(entry[key].as_str().unwrap_or("")).contains(&query))
|
||||
|| entry["tags"].as_array().is_some_and(|tags| {
|
||||
tags.iter()
|
||||
.any(|tag| lowercase(tag.as_str().unwrap_or("")).contains(&query))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::{RepositoryError, cause, error, normalize_sha256};
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_core::filename::sanitize_filename;
|
||||
use spotiflac_core::matching::lowercase;
|
||||
use spotiflac_network::url::UrlParts;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_PACKAGE_BYTES: u64 = 64 << 20;
|
||||
|
||||
pub fn destination_path(directory: &Path, id: &str, url: &str) -> Result<PathBuf, RepositoryError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(error("invalid extension id"));
|
||||
}
|
||||
let path = UrlParts::parse(url)
|
||||
.map_or_else(|| url.into(), |url| crate::host::decode_go_utf8(&url.path));
|
||||
let suffix = if lowercase(&path).ends_with(".sflx") {
|
||||
".sflx"
|
||||
} else {
|
||||
".spotiflac-ext"
|
||||
};
|
||||
Ok(crate::files::clean(
|
||||
&directory.join(format!("{}{suffix}", sanitize_filename(id))),
|
||||
))
|
||||
}
|
||||
|
||||
/// Streams to a private sibling file and publishes only verified bytes. An
|
||||
/// interrupted reader, oversized body or checksum mismatch preserves the old
|
||||
/// destination. Native callers own the destination directory during this call.
|
||||
pub fn write_verified_package(
|
||||
mut read: impl FnMut(&mut [u8]) -> Result<usize, String>,
|
||||
destination: &Path,
|
||||
expected: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<(), RepositoryError> {
|
||||
check().map_err(error)?;
|
||||
let directory = destination
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
fs::create_dir_all(directory)
|
||||
.map_err(|e| cause("failed to prepare extension download directory", e))?;
|
||||
let name = destination
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy();
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(&format!(".{name}.download-"))
|
||||
.tempfile_in(directory)
|
||||
.map_err(|e| cause("failed to create extension download", e))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut written = 0u64;
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
check().map_err(error)?;
|
||||
let limit = buffer.len().min((MAX_PACKAGE_BYTES + 1 - written) as usize);
|
||||
let count = read(&mut buffer[..limit])
|
||||
.map_err(|e| cause("failed to write extension package", e))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
if count > limit {
|
||||
return Err(error("invalid extension package reader length"));
|
||||
}
|
||||
written += count as u64;
|
||||
if written > MAX_PACKAGE_BYTES {
|
||||
return Err(error("extension package exceeds the 64 MiB size limit"));
|
||||
}
|
||||
file.write_all(&buffer[..count])
|
||||
.map_err(|e| cause("failed to write extension package", e))?;
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
file.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| cause("failed to flush extension package", e))?;
|
||||
let normalized = normalize_sha256(expected);
|
||||
if !expected.trim().is_empty() && normalized.is_empty() {
|
||||
return Err(error(
|
||||
"registry contains an invalid extension SHA-256 checksum",
|
||||
));
|
||||
}
|
||||
if !normalized.is_empty() {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
// The checksum is public, but keep the comparison independent of its
|
||||
// first mismatching byte, matching the Go implementation.
|
||||
if actual
|
||||
.bytes()
|
||||
.zip(normalized.bytes())
|
||||
.fold(0, |difference, (a, b)| difference | (a ^ b))
|
||||
!= 0
|
||||
{
|
||||
return Err(error(
|
||||
"extension package integrity check failed: SHA-256 mismatch",
|
||||
));
|
||||
}
|
||||
}
|
||||
check().map_err(error)?;
|
||||
file.persist(destination)
|
||||
.map_err(|e| cause("failed to publish extension package", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::{RepositoryError, error};
|
||||
use spotiflac_network::url::UrlParts;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RegistryLocation {
|
||||
Direct(String),
|
||||
GitHub { owner: String, repository: String },
|
||||
}
|
||||
|
||||
pub fn registry_location(input: &str) -> Result<RegistryLocation, RepositoryError> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(error("registry URL is empty"));
|
||||
}
|
||||
if input.contains("raw.githubusercontent.com") {
|
||||
return Ok(RegistryLocation::Direct(input.into()));
|
||||
}
|
||||
let Some(path) = input
|
||||
.strip_prefix("https://github.com/")
|
||||
.or_else(|| input.strip_prefix("http://github.com/"))
|
||||
else {
|
||||
return Ok(RegistryLocation::Direct(input.into()));
|
||||
};
|
||||
let mut parts = path.splitn(3, '/');
|
||||
let owner = parts.next().unwrap_or("");
|
||||
let repository = parts.next().unwrap_or("");
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err(error(
|
||||
"invalid GitHub URL: expected github.com/<owner>/<repo>",
|
||||
));
|
||||
}
|
||||
Ok(RegistryLocation::GitHub {
|
||||
owner: owner.into(),
|
||||
repository: repository.strip_suffix(".git").unwrap_or(repository).into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn require_https(input: &str, context: &str) -> Result<(), RepositoryError> {
|
||||
if input.is_empty() {
|
||||
return Err(error(format!("{context} URL is empty")));
|
||||
}
|
||||
let url = UrlParts::parse(input)
|
||||
.filter(|url| !url.hostname.is_empty())
|
||||
.ok_or_else(|| error(format!("{context} URL is invalid: {input}")))?;
|
||||
if url.scheme != "https" {
|
||||
return Err(error(format!("{context} URL must use https: {input}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Resolver allowance shared by a download call and its native segment workers.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(crate) struct ResolutionBudget(Mutex<State>);
|
||||
|
||||
struct State {
|
||||
remaining: Duration,
|
||||
started: Instant,
|
||||
pauses: usize,
|
||||
charges: usize,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn remaining(&self, now: Instant) -> Duration {
|
||||
if self.pauses == 0 || self.charges > 0 {
|
||||
self.remaining
|
||||
.saturating_sub(now.saturating_duration_since(self.started))
|
||||
} else {
|
||||
self.remaining
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(&mut self, now: Instant) {
|
||||
self.remaining = self.remaining(now);
|
||||
self.started = now;
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolutionBudget {
|
||||
pub fn new(allowance: Duration) -> Arc<Self> {
|
||||
Arc::new(Self(Mutex::new(State {
|
||||
remaining: allowance,
|
||||
started: Instant::now(),
|
||||
pauses: 0,
|
||||
charges: 0,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> Duration {
|
||||
self.0
|
||||
.lock()
|
||||
.expect("resolution budget lock")
|
||||
.remaining(Instant::now())
|
||||
}
|
||||
|
||||
pub fn enter(self: &Arc<Self>, charge: bool) -> BudgetGuard {
|
||||
let mut state = self.0.lock().expect("resolution budget lock");
|
||||
state.settle(Instant::now());
|
||||
if charge {
|
||||
state.charges += 1;
|
||||
} else {
|
||||
state.pauses += 1;
|
||||
}
|
||||
BudgetGuard {
|
||||
budget: Arc::clone(self),
|
||||
charge,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BudgetGuard {
|
||||
budget: Arc<ResolutionBudget>,
|
||||
charge: bool,
|
||||
}
|
||||
|
||||
impl Drop for BudgetGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.budget.0.lock().expect("resolution budget lock");
|
||||
state.settle(Instant::now());
|
||||
if self.charge {
|
||||
state.charges -= 1;
|
||||
} else {
|
||||
state.pauses -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn charges_override_nested_pauses_and_exhaustion_is_terminal() {
|
||||
let now = Instant::now();
|
||||
let mut state = State {
|
||||
remaining: Duration::from_secs(60),
|
||||
started: now,
|
||||
pauses: 0,
|
||||
charges: 0,
|
||||
};
|
||||
state.settle(now + Duration::from_secs(10));
|
||||
state.pauses = 2;
|
||||
assert_eq!(
|
||||
state.remaining(now + Duration::from_secs(3600)),
|
||||
Duration::from_secs(50)
|
||||
);
|
||||
state.settle(now + Duration::from_secs(3600));
|
||||
state.charges = 1;
|
||||
state.settle(now + Duration::from_secs(3610));
|
||||
assert_eq!(state.remaining, Duration::from_secs(40));
|
||||
state.charges = 0;
|
||||
state.settle(now + Duration::from_secs(4000));
|
||||
state.pauses = 0;
|
||||
state.settle(now + Duration::from_secs(4041));
|
||||
assert_eq!(state.remaining, Duration::ZERO);
|
||||
state.pauses = 1;
|
||||
assert_eq!(
|
||||
state.remaining(now + Duration::from_secs(5000)),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use rquickjs::{Ctx, Function, Object};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
host.set("sessionEnabled", services.session.is_some())?;
|
||||
if let Some(session) = &services.session {
|
||||
let session = Arc::clone(session);
|
||||
host.set(
|
||||
"sessionCall",
|
||||
Function::new(ctx.clone(), move |method: String, arguments: String| {
|
||||
let arguments = Zeroizing::new(arguments);
|
||||
let check = || control.check().map_err(|error| error.to_string());
|
||||
let result = (|| {
|
||||
check()?;
|
||||
let args: Vec<Value> =
|
||||
serde_json::from_str(&arguments).map_err(|error| error.to_string())?;
|
||||
let string = |index| args.get(index).and_then(Value::as_str).unwrap_or("");
|
||||
match method.as_str() {
|
||||
"status" => session.status(),
|
||||
"clear" => session.clear().map(|()| json!({"success":true})),
|
||||
"completeGrant" => session
|
||||
.complete_grant(string(0), check)
|
||||
.map(|()| json!({"success":true})),
|
||||
"signedFetch" => {
|
||||
if args.len() < 2 {
|
||||
return Err("method and path are required".into());
|
||||
}
|
||||
let headers: BTreeMap<String, String> = args
|
||||
.get(3)
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|error| error.to_string())?
|
||||
.unwrap_or_default();
|
||||
session.signed_fetch(string(0), string(1), string(2), &headers, check)
|
||||
}
|
||||
_ => Err("unknown signed session method".into()),
|
||||
}
|
||||
})();
|
||||
match result {
|
||||
Ok(value) => value,
|
||||
Err(error) if method == "status" => {
|
||||
json!({"authenticated":false,"error":error})
|
||||
}
|
||||
Err(error) if method == "signedFetch" => json!({"ok":false,"error":error}),
|
||||
Err(error) => json!({"success":false,"error":error}),
|
||||
}
|
||||
.to_string()
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Gateway signed sessions shared by scope, with isolated extension permissions.
|
||||
|
||||
mod coordinator;
|
||||
mod exchange;
|
||||
mod fetch;
|
||||
pub mod protocol;
|
||||
mod store;
|
||||
|
||||
pub use coordinator::{SignedRegistry, SignedSessionClient};
|
||||
pub use store::{RecordStore, RuntimeHints};
|
||||
@@ -0,0 +1,319 @@
|
||||
use super::{RecordStore, RuntimeHints, protocol};
|
||||
use crate::auth::{AuthRegistry, PendingAuthRequest};
|
||||
use crate::manifest::SignedSession;
|
||||
use serde_json::{Value, json};
|
||||
use spotiflac_network::{HttpRequest, HttpResponse, NetworkSession};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(super) type Check<'a> = &'a dyn Fn() -> Result<(), String>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Flight {
|
||||
result: Mutex<Option<Result<(), String>>>,
|
||||
done: Condvar,
|
||||
}
|
||||
|
||||
impl Flight {
|
||||
pub fn finish(&self, result: Result<(), String>) {
|
||||
*self.result.lock().expect("signed session flight lock") = Some(result);
|
||||
self.done.notify_all();
|
||||
}
|
||||
|
||||
pub fn wait(&self, check: Check<'_>) -> Result<(), String> {
|
||||
let mut result = self.result.lock().expect("signed session flight lock");
|
||||
loop {
|
||||
check()?;
|
||||
if let Some(result) = &*result {
|
||||
return result.clone();
|
||||
}
|
||||
result = self
|
||||
.done
|
||||
.wait_timeout(result, Duration::from_millis(10))
|
||||
.expect("signed session flight wait")
|
||||
.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
pub clear_generation: u64,
|
||||
pub completed_grant: String,
|
||||
pub blocked_generation: String,
|
||||
pub challenge: Option<PendingAuthRequest>,
|
||||
pub pending_ids: BTreeSet<String>,
|
||||
pub bootstrap: Option<Arc<Flight>>,
|
||||
pub refresh: Option<Arc<Flight>>,
|
||||
pub exchange: Option<Arc<Flight>>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn blocked(&self, record: &protocol::Record) -> bool {
|
||||
let generation = record.generation();
|
||||
!generation.is_empty() && self.blocked_generation == generation
|
||||
}
|
||||
|
||||
pub fn clear_challenge(&mut self, auth: &AuthRegistry) {
|
||||
for id in &self.pending_ids {
|
||||
auth.clear_pending(id);
|
||||
}
|
||||
self.pending_ids.clear();
|
||||
self.challenge = None;
|
||||
}
|
||||
|
||||
pub fn remember(&mut self, pending: PendingAuthRequest) {
|
||||
self.pending_ids.insert(pending.extension_id.clone());
|
||||
self.challenge = Some(pending);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SignedRegistry {
|
||||
root: PathBuf,
|
||||
pub(super) auth: Arc<AuthRegistry>,
|
||||
hints: Mutex<RuntimeHints>,
|
||||
scopes: Mutex<BTreeMap<PathBuf, Arc<Mutex<State>>>>,
|
||||
pub(super) grants: Mutex<BTreeMap<String, Zeroizing<String>>>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl SignedRegistry {
|
||||
pub fn new(root: &Path, auth: Arc<AuthRegistry>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
root: root.to_owned(),
|
||||
auth,
|
||||
hints: Mutex::default(),
|
||||
scopes: Mutex::default(),
|
||||
grants: Mutex::default(),
|
||||
closed: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_runtime_state(&self, raw: &str) -> Result<(), String> {
|
||||
self.check()?;
|
||||
*self.hints.lock().expect("session hints lock") = RuntimeHints::parse(raw);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_grant(&self, id: &str, grant: &str) -> Result<(), String> {
|
||||
let mut grants = self.grants.lock().expect("session grants lock");
|
||||
self.check()?;
|
||||
if !id.trim().is_empty() && !grant.trim().is_empty() {
|
||||
grants.insert(id.trim().into(), Zeroizing::new(grant.trim().into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
config: SignedSession,
|
||||
network: Arc<NetworkSession>,
|
||||
) -> Result<Arc<SignedSessionClient>, String> {
|
||||
self.check()?;
|
||||
let config = protocol::defaults(config);
|
||||
let store = RecordStore::open(&self.root, &config)?;
|
||||
let mut scopes = self.scopes.lock().expect("signed session scopes lock");
|
||||
self.check()?;
|
||||
let scope = Arc::clone(scopes.entry(store.path().to_owned()).or_default());
|
||||
Ok(Arc::new(SignedSessionClient {
|
||||
id: id.to_owned(),
|
||||
registry: Arc::clone(self),
|
||||
config,
|
||||
store,
|
||||
scope,
|
||||
network,
|
||||
verification_url: Mutex::new(String::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn check(&self) -> Result<(), String> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
Err("extension environment closed".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
self.grants.lock().expect("session grants lock").clear();
|
||||
for state in self
|
||||
.scopes
|
||||
.lock()
|
||||
.expect("signed session scopes lock")
|
||||
.values()
|
||||
{
|
||||
state
|
||||
.lock()
|
||||
.expect("signed session coordinator lock")
|
||||
.clear_challenge(&self.auth);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn forget_extension(&self, id: &str) {
|
||||
self.grants.lock().expect("session grants lock").remove(id);
|
||||
// Session records can be shared by multiple extensions through their
|
||||
// manifest namespace. Only remove this extension's pending challenge.
|
||||
for scope in self
|
||||
.scopes
|
||||
.lock()
|
||||
.expect("signed session scopes lock")
|
||||
.values()
|
||||
{
|
||||
let mut state = scope.lock().expect("signed session coordinator lock");
|
||||
state.pending_ids.remove(id);
|
||||
if state
|
||||
.challenge
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.extension_id == id)
|
||||
{
|
||||
state.challenge = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SignedSessionClient {
|
||||
pub(super) id: String,
|
||||
pub(super) registry: Arc<SignedRegistry>,
|
||||
pub(super) config: SignedSession,
|
||||
pub(super) store: RecordStore,
|
||||
pub(super) scope: Arc<Mutex<State>>,
|
||||
pub(super) network: Arc<NetworkSession>,
|
||||
verification_url: Mutex<String>,
|
||||
}
|
||||
|
||||
impl SignedSessionClient {
|
||||
pub(super) fn check(&self, check: Check<'_>) -> Result<(), String> {
|
||||
self.registry.check()?;
|
||||
check()
|
||||
}
|
||||
|
||||
pub(super) fn load(&self) -> Result<protocol::Record, String> {
|
||||
self.store
|
||||
.load(&self.registry.hints.lock().expect("session hints lock"))
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Result<Value, String> {
|
||||
self.registry.check()?;
|
||||
let state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let record = self.load()?;
|
||||
let blocked = state.blocked(&record);
|
||||
Ok(
|
||||
json!({"authenticated":record.usable(self.registry.auth.now()) && !blocked, "verification_required":blocked,
|
||||
"expires_at":record.expires_at,"install_id":record.install_id,"session_id":record.session_id,
|
||||
"app_version":self.config.app_version,"platform":self.config.platform}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), String> {
|
||||
self.registry.check()?;
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let mut record = self.load()?;
|
||||
record.clear();
|
||||
self.store.save(&record)?;
|
||||
state.clear_generation = state.clear_generation.wrapping_add(1);
|
||||
state.completed_grant.clear();
|
||||
state.blocked_generation.clear();
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
self.registry.auth.clear_pending(&self.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn take_verification_url(&self) -> String {
|
||||
std::mem::take(
|
||||
&mut self
|
||||
.verification_url
|
||||
.lock()
|
||||
.expect("signed session verification URL lock"),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn verification_required(&self, url: String) -> Value {
|
||||
if !url.is_empty() {
|
||||
*self
|
||||
.verification_url
|
||||
.lock()
|
||||
.expect("signed session verification URL lock") = url.clone();
|
||||
}
|
||||
json!({"ok":false,"needsVerification":true,"error":"VERIFY_REQUIRED","open_auth_url":url,"auth_url":url})
|
||||
}
|
||||
|
||||
pub(super) fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
url: String,
|
||||
body: String,
|
||||
mut headers: BTreeMap<String, String>,
|
||||
check: Check<'_>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
self.check(check)?;
|
||||
// Header matching is case-insensitive on the wire. Normalize before
|
||||
// applying extension overrides so a lowercase override cannot coexist.
|
||||
headers = headers
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_ascii_lowercase(), value))
|
||||
.collect();
|
||||
headers
|
||||
.entry("accept".into())
|
||||
.or_insert_with(|| "application/json".into());
|
||||
self.network.request(
|
||||
HttpRequest {
|
||||
url,
|
||||
method: method.into(),
|
||||
body,
|
||||
headers,
|
||||
default_json: false,
|
||||
user_agent: format!("SpotiFLAC-Mobile/{}", self.config.app_version),
|
||||
},
|
||||
|| self.check(check),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn signed_request(
|
||||
&self,
|
||||
record: &protocol::Record,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &str,
|
||||
extras: &BTreeMap<String, String>,
|
||||
check: Check<'_>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
let url = protocol::endpoint(&self.config, path)?;
|
||||
let mut headers: BTreeMap<_, _> = protocol::signed_headers(
|
||||
&self.config,
|
||||
record,
|
||||
method,
|
||||
&url,
|
||||
body.as_bytes(),
|
||||
self.registry.auth.now(),
|
||||
&protocol::random_hex(12)?,
|
||||
)?
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_ascii_lowercase(), value))
|
||||
.collect();
|
||||
if !body.is_empty() {
|
||||
headers.insert("content-type".into(), "application/json".into());
|
||||
}
|
||||
for (key, value) in extras {
|
||||
headers.insert(key.to_ascii_lowercase(), value.clone());
|
||||
}
|
||||
self.request(method, url, body.into(), headers, check)
|
||||
}
|
||||
|
||||
pub(super) fn wait(&self, delay: Duration, check: Check<'_>) -> Result<(), String> {
|
||||
let end = Instant::now() + delay;
|
||||
loop {
|
||||
self.check(check)?;
|
||||
let Some(remaining) = end.checked_duration_since(Instant::now()) else {
|
||||
return Ok(());
|
||||
};
|
||||
std::thread::sleep(remaining.min(Duration::from_millis(10)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
use super::coordinator::{Check, Flight, SignedSessionClient};
|
||||
use super::protocol::{self, Record};
|
||||
use crate::auth::{PendingAuthRequest, callback_state};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_network::{query, url::UrlParts};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
enum Bootstrap {
|
||||
Session(Record),
|
||||
Challenge {
|
||||
url: String,
|
||||
callback: String,
|
||||
state: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn response_record(body: &[u8]) -> Result<Record, String> {
|
||||
let fields = protocol::object_fields(
|
||||
body,
|
||||
&[
|
||||
"session_id",
|
||||
"session_secret",
|
||||
"expires_at",
|
||||
"challenge_id",
|
||||
"challenge_url",
|
||||
"auth_url",
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
)?;
|
||||
serde_json::from_value(Value::Object(fields)).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
impl SignedSessionClient {
|
||||
pub fn preflight(&self, check: impl Fn() -> Result<(), String>) -> Result<bool, String> {
|
||||
self.check(&check)?;
|
||||
{
|
||||
let state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let record = self.load()?;
|
||||
if record.usable(self.registry.auth.now()) && !state.blocked(&record) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
let url = self.bootstrap(&check)?;
|
||||
if !url.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
let _state = self.scope.lock().expect("signed session coordinator lock");
|
||||
if self.load()?.usable(self.registry.auth.now()) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(
|
||||
"signed-session bootstrap did not return a session or verification challenge"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bootstrap(&self, check: Check<'_>) -> Result<String, String> {
|
||||
loop {
|
||||
self.check(check)?;
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
if let Some(challenge) = state.challenge.as_ref().filter(|challenge| {
|
||||
!challenge.auth_url.trim().is_empty()
|
||||
&& self.registry.auth.now() - challenge.created_at < 180_000_000_000
|
||||
}) {
|
||||
let mut pending = challenge.clone();
|
||||
pending.extension_id = self.id.clone();
|
||||
self.registry.auth.register_pending(pending.clone())?;
|
||||
state.pending_ids.insert(self.id.clone());
|
||||
return Ok(pending.auth_url);
|
||||
}
|
||||
if state.challenge.is_some() {
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
}
|
||||
if let Some(pending) = self
|
||||
.registry
|
||||
.auth
|
||||
.pending(&self.id)
|
||||
.filter(|pending| !pending.auth_url.trim().is_empty())
|
||||
{
|
||||
let url = pending.auth_url.clone();
|
||||
state.remember(pending);
|
||||
return Ok(url);
|
||||
}
|
||||
let record = self
|
||||
.load()
|
||||
.map_err(|error| format!("load signed-session bootstrap state: {error}"))?;
|
||||
if record.usable(self.registry.auth.now()) && !state.blocked(&record) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if let Some(flight) = state.bootstrap.clone() {
|
||||
drop(state);
|
||||
flight.wait(&|| self.check(check))?;
|
||||
continue;
|
||||
}
|
||||
let flight = Arc::new(Flight::default());
|
||||
state.bootstrap = Some(Arc::clone(&flight));
|
||||
let generation = state.clear_generation;
|
||||
drop(state);
|
||||
let bootstrap = self.perform_bootstrap(&record, check);
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let result = (|| {
|
||||
let bootstrap = bootstrap?;
|
||||
self.check(check)?;
|
||||
if state.clear_generation != generation {
|
||||
return Err("signed-session bootstrap was superseded by session clear".into());
|
||||
}
|
||||
let mut latest = self.load()?;
|
||||
if latest.usable(self.registry.auth.now())
|
||||
&& !latest.same_session(&record)
|
||||
&& !state.blocked(&latest)
|
||||
{
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
return Ok(String::new());
|
||||
}
|
||||
match bootstrap {
|
||||
Bootstrap::Session(record) => {
|
||||
latest.session_id.clone_from(&record.session_id);
|
||||
latest.session_secret.clone_from(&record.session_secret);
|
||||
latest.expires_at.clone_from(&record.expires_at);
|
||||
self.store.save(&latest).map_err(|error| {
|
||||
format!("save bootstrapped signed session: {error}")
|
||||
})?;
|
||||
state.blocked_generation.clear();
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
Ok(String::new())
|
||||
}
|
||||
Bootstrap::Challenge {
|
||||
url,
|
||||
callback,
|
||||
state: nonce,
|
||||
} => {
|
||||
let pending = PendingAuthRequest {
|
||||
extension_id: self.id.clone(),
|
||||
auth_url: url.clone(),
|
||||
callback_url: callback,
|
||||
state: nonce,
|
||||
created_at: self.registry.auth.now(),
|
||||
};
|
||||
self.registry.auth.register_pending(pending.clone())?;
|
||||
state.remember(pending);
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
})();
|
||||
state.bootstrap = None;
|
||||
flight.finish(result.as_ref().map(|_| ()).map_err(Clone::clone));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_bootstrap(&self, record: &Record, check: Check<'_>) -> Result<Bootstrap, String> {
|
||||
let url = protocol::endpoint(&self.config, &self.config.endpoints.bootstrap)
|
||||
.map_err(|error| format!("build signed-session bootstrap URL: {error}"))?;
|
||||
let url = protocol::with_query(
|
||||
&url,
|
||||
&[
|
||||
("app_version", &self.config.app_version),
|
||||
("install_id", &record.install_id),
|
||||
],
|
||||
)?;
|
||||
let mut response = self.request("GET", url.clone(), String::new(), BTreeMap::new(), check);
|
||||
if response.is_err() {
|
||||
self.check(check)?;
|
||||
self.network.reset_connections();
|
||||
response = self.request("GET", url.clone(), String::new(), BTreeMap::new(), check);
|
||||
}
|
||||
let response = response.map_err(|error| {
|
||||
format!(
|
||||
"signed-session bootstrap network request to {} failed: {error}",
|
||||
UrlParts::parse(&url)
|
||||
.map(|url| url.authority())
|
||||
.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
let mut error = format!(
|
||||
"signed-session bootstrap {}returned HTTP {}",
|
||||
if response.status >= 500 {
|
||||
"network request "
|
||||
} else {
|
||||
""
|
||||
},
|
||||
response.status
|
||||
);
|
||||
let retry = protocol::response_retry_after(&response, self.registry.auth.now());
|
||||
if retry > 0 {
|
||||
error += &format!("; retry-after seconds: {retry}");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
let body = Zeroizing::new(response.body);
|
||||
let record = response_record(&body)
|
||||
.map_err(|error| format!("decode signed-session bootstrap response: {error}"))?;
|
||||
let boot = protocol::object_fields(
|
||||
&body,
|
||||
&["auth_url", "challenge_url", "challenge_id"],
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.map_err(|error| format!("decode signed-session bootstrap response: {error}"))?;
|
||||
if !record.session_id.is_empty()
|
||||
&& !record.session_secret.is_empty()
|
||||
&& !record.expires_at.is_empty()
|
||||
{
|
||||
return Ok(Bootstrap::Session(record));
|
||||
}
|
||||
let text = |key| boot.get(key).and_then(Value::as_str).unwrap_or("");
|
||||
let mut auth_url = text("auth_url").to_owned();
|
||||
if auth_url.is_empty() {
|
||||
auth_url = text("challenge_url").to_owned();
|
||||
}
|
||||
let mut state = callback_state()
|
||||
.map_err(|error| format!("prepare signed-session callback state: {error}"))?;
|
||||
if let Some(url) = UrlParts::parse(&auth_url) {
|
||||
let values = query::parse(&url.raw_query);
|
||||
let server_state = values
|
||||
.get(b"state".as_slice())
|
||||
.and_then(|values| values.first())
|
||||
.map(|bytes| crate::host::decode_go_utf8(bytes))
|
||||
.unwrap_or_default();
|
||||
if !server_state.trim().is_empty() {
|
||||
state = server_state.trim().to_owned();
|
||||
} else if !auth_url.is_empty() {
|
||||
auth_url = protocol::with_query(&auth_url, &[("state", &state)])?;
|
||||
}
|
||||
}
|
||||
let callback = protocol::with_query(&self.config.callback_url, &[("state", &state)])
|
||||
.map_err(|error| format!("prepare signed-session callback: {error}"))?;
|
||||
if auth_url.is_empty() && !text("challenge_id").is_empty() {
|
||||
auth_url = protocol::challenge_url(&self.config, text("challenge_id"), &state)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if auth_url.is_empty() {
|
||||
return Err(
|
||||
"signed-session bootstrap did not return a session or verification challenge"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(Bootstrap::Challenge {
|
||||
url: auth_url,
|
||||
callback,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn complete_grant(
|
||||
&self,
|
||||
grant: &str,
|
||||
check: impl Fn() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
self.registry.set_grant(&self.id, grant)?;
|
||||
let grant = self
|
||||
.registry
|
||||
.grants
|
||||
.lock()
|
||||
.expect("session grants lock")
|
||||
.get(&self.id)
|
||||
.cloned()
|
||||
.filter(|grant| !grant.is_empty())
|
||||
.ok_or("no pending grant")?;
|
||||
let end = Instant::now() + Duration::from_secs(30);
|
||||
let check = || {
|
||||
self.check(&check)?;
|
||||
if Instant::now() >= end {
|
||||
Err("context deadline exceeded".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
self.exchange(&grant, &check)?;
|
||||
let mut grants = self.registry.grants.lock().expect("session grants lock");
|
||||
// A newer callback can arrive during HTTP. Do not discard its grant.
|
||||
if grants
|
||||
.get(&self.id)
|
||||
.is_some_and(|pending| pending.as_str() == grant.as_str())
|
||||
{
|
||||
grants.remove(&self.id);
|
||||
}
|
||||
self.registry.auth.clear_pending(&self.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exchange(&self, grant: &str, check: Check<'_>) -> Result<(), String> {
|
||||
let generation = self
|
||||
.scope
|
||||
.lock()
|
||||
.expect("signed session coordinator lock")
|
||||
.clear_generation;
|
||||
let flight = loop {
|
||||
self.check(check)?;
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
if let Some(flight) = state.exchange.clone() {
|
||||
drop(state);
|
||||
// Exchange slots serialize different grants. The preceding
|
||||
// grant's failure is not the outcome of this new operation.
|
||||
let _ = flight.wait(&|| self.check(check));
|
||||
self.check(check)?;
|
||||
} else {
|
||||
let flight = Arc::new(Flight::default());
|
||||
state.exchange = Some(Arc::clone(&flight));
|
||||
break flight;
|
||||
}
|
||||
};
|
||||
let result = self.exchange_owned(grant, generation, check);
|
||||
self.scope
|
||||
.lock()
|
||||
.expect("signed session coordinator lock")
|
||||
.exchange = None;
|
||||
flight.finish(result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
fn exchange_owned(&self, grant: &str, generation: u64, check: Check<'_>) -> Result<(), String> {
|
||||
let grant_hash = format!("{:x}", Sha256::digest(grant));
|
||||
let record = {
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
if state.clear_generation != generation {
|
||||
return Err("signed-session exchange was superseded by session clear".into());
|
||||
}
|
||||
let record = self.load()?;
|
||||
if state.completed_grant == grant_hash && record.usable(self.registry.auth.now()) {
|
||||
state.blocked_generation.clear();
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
return Ok(());
|
||||
}
|
||||
record
|
||||
};
|
||||
let url = protocol::endpoint(&self.config, &self.config.endpoints.exchange)?;
|
||||
let body = Zeroizing::new(json!({"grant":grant,"install_id":record.install_id,"app_version":self.config.app_version,"platform":self.config.platform}).to_string());
|
||||
let mut exchanged = None;
|
||||
for attempt in 0..3 {
|
||||
let response = self.request(
|
||||
"POST",
|
||||
url.clone(),
|
||||
body.to_string(),
|
||||
BTreeMap::from([("content-type".into(), "application/json".into())]),
|
||||
check,
|
||||
)?;
|
||||
let retry = protocol::response_retry_after(&response, self.registry.auth.now());
|
||||
if response.status == 429 && attempt < 2 {
|
||||
self.wait(Duration::from_secs(retry.clamp(1, 300) as u64), check)?;
|
||||
continue;
|
||||
}
|
||||
if !(200..300).contains(&response.status) {
|
||||
let mut error = format!("session exchange failed: HTTP {}", response.status);
|
||||
if retry > 0 {
|
||||
error += &format!("; retry-after seconds: {retry}");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
exchanged = Some(
|
||||
response_record(&Zeroizing::new(response.body))
|
||||
.map_err(|error| format!("invalid session exchange response: {error}"))?,
|
||||
);
|
||||
break;
|
||||
}
|
||||
let exchanged = exchanged.ok_or("session exchange did not complete")?;
|
||||
if exchanged.session_id.is_empty()
|
||||
|| exchanged.session_secret.is_empty()
|
||||
|| exchanged.expires_at.is_empty()
|
||||
{
|
||||
return Err("session exchange response missing session fields".into());
|
||||
}
|
||||
self.check(check)?;
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
if state.clear_generation != generation {
|
||||
return Err("signed-session exchange was superseded by session clear".into());
|
||||
}
|
||||
let mut latest = self.load()?;
|
||||
if !(latest.usable(self.registry.auth.now())
|
||||
&& (state.completed_grant == grant_hash || !latest.same_session(&record)))
|
||||
{
|
||||
latest.session_id.clone_from(&exchanged.session_id);
|
||||
latest.session_secret.clone_from(&exchanged.session_secret);
|
||||
latest.expires_at.clone_from(&exchanged.expires_at);
|
||||
self.store.save(&latest)?;
|
||||
state.completed_grant = grant_hash;
|
||||
}
|
||||
state.blocked_generation.clear();
|
||||
state.clear_challenge(&self.registry.auth);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn refresh(&self, check: Check<'_>) -> Result<(), String> {
|
||||
loop {
|
||||
self.check(check)?;
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let record = self.load()?;
|
||||
if !record.refresh_due(&self.config, self.registry.auth.now()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(flight) = state.refresh.clone() {
|
||||
drop(state);
|
||||
flight.wait(&|| self.check(check))?;
|
||||
continue;
|
||||
}
|
||||
let flight = Arc::new(Flight::default());
|
||||
state.refresh = Some(Arc::clone(&flight));
|
||||
let generation = state.clear_generation;
|
||||
drop(state);
|
||||
let response = self.signed_request(
|
||||
&record,
|
||||
"POST",
|
||||
&self.config.endpoints.refresh,
|
||||
&json!({"install_id":record.install_id}).to_string(),
|
||||
&BTreeMap::new(),
|
||||
check,
|
||||
);
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let result = (|| {
|
||||
let response = response?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
return Err(format!("session refresh failed: HTTP {}", response.status));
|
||||
}
|
||||
let refreshed = response_record(&Zeroizing::new(response.body))?;
|
||||
self.check(check)?;
|
||||
if state.clear_generation != generation {
|
||||
return Err("signed-session refresh was superseded by session clear".into());
|
||||
}
|
||||
let mut latest = self.load()?;
|
||||
if latest.same_session(&record) {
|
||||
let mut changed = false;
|
||||
for (field, value) in [
|
||||
(&mut latest.session_id, &refreshed.session_id),
|
||||
(&mut latest.session_secret, &refreshed.session_secret),
|
||||
(&mut latest.expires_at, &refreshed.expires_at),
|
||||
] {
|
||||
if !value.is_empty() && field != value {
|
||||
field.clone_from(value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.store.save(&latest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
state.refresh = None;
|
||||
flight.finish(result.clone());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use super::coordinator::SignedSessionClient;
|
||||
use super::protocol::{self, ErrorContract};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
impl SignedSessionClient {
|
||||
pub fn signed_fetch(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &str,
|
||||
headers: &BTreeMap<String, String>,
|
||||
check: impl Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
self.check(&check)?;
|
||||
let method = method.trim().to_uppercase();
|
||||
let mut record = {
|
||||
let state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let mut record = self.load()?;
|
||||
let error = if record.session_id.is_empty() || record.session_secret.is_empty() {
|
||||
Some("signed session is not authenticated")
|
||||
} else if protocol::parse_time(&record.expires_at)
|
||||
.is_some_and(|expires| self.registry.auth.now() > expires)
|
||||
{
|
||||
record.clear();
|
||||
let _ = self.store.save(&record);
|
||||
Some("signed session expired")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(error) = error {
|
||||
drop(state);
|
||||
let url = self.bootstrap(&check)?;
|
||||
return if url.is_empty() {
|
||||
Err(error.into())
|
||||
} else {
|
||||
Ok(self.verification_required(url))
|
||||
};
|
||||
}
|
||||
if state.blocked(&record) {
|
||||
drop(state);
|
||||
let url = self.bootstrap(&check)?;
|
||||
if !url.is_empty() {
|
||||
return Ok(self.verification_required(url));
|
||||
}
|
||||
let state = self.scope.lock().expect("signed session coordinator lock");
|
||||
record = self.load()?;
|
||||
if !record.usable(self.registry.auth.now()) || state.blocked(&record) {
|
||||
return Err(
|
||||
"verification_required: signed-session generation is blocked".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
record
|
||||
};
|
||||
if record.refresh_due(&self.config, self.registry.auth.now()) {
|
||||
let _ = self.refresh(&check);
|
||||
self.check(&check)?;
|
||||
let state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let latest = self.load()?;
|
||||
if !latest.usable(self.registry.auth.now()) || state.blocked(&latest) {
|
||||
drop(state);
|
||||
let url = self.bootstrap(&check)?;
|
||||
return if url.is_empty() {
|
||||
Err("signed session is not authenticated".into())
|
||||
} else {
|
||||
Ok(self.verification_required(url))
|
||||
};
|
||||
}
|
||||
record = latest;
|
||||
}
|
||||
let mut session_retries = 0;
|
||||
let mut provider_retries = 0;
|
||||
let mut request_auth_retry = false;
|
||||
loop {
|
||||
let response = self.signed_request(&record, &method, path, body, headers, &check)?;
|
||||
let contract = ErrorContract::parse(&response.body);
|
||||
if contract.provider_retry(response.status) {
|
||||
if provider_retries >= 2 {
|
||||
return Ok(protocol::response_value(response, self.registry.auth.now()));
|
||||
}
|
||||
provider_retries += 1;
|
||||
self.wait(
|
||||
protocol::provider_delay(&response, &contract, self.registry.auth.now()),
|
||||
&check,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if contract.request_auth_invalid(response.status) {
|
||||
let _state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let latest = self.load()?;
|
||||
if !request_auth_retry
|
||||
&& latest.usable(self.registry.auth.now())
|
||||
&& !latest.same_session(&record)
|
||||
{
|
||||
request_auth_retry = true;
|
||||
record = latest;
|
||||
continue;
|
||||
}
|
||||
return Ok(protocol::response_value(response, self.registry.auth.now()));
|
||||
}
|
||||
let action = contract.gateway_action(response.status);
|
||||
if action.is_empty() {
|
||||
return Ok(protocol::response_value(response, self.registry.auth.now()));
|
||||
}
|
||||
let mut state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let mut latest = self.load()?;
|
||||
if latest.usable(self.registry.auth.now()) && !latest.same_session(&record) {
|
||||
if session_retries >= 1 {
|
||||
return Err("signed-session retry limit reached".into());
|
||||
}
|
||||
session_retries += 1;
|
||||
record = latest;
|
||||
continue;
|
||||
}
|
||||
if action == "bootstrap_session" && latest.same_session(&record) {
|
||||
state.blocked_generation.clear();
|
||||
latest.clear();
|
||||
self.store.save(&latest)?;
|
||||
} else if action == "verify" && latest.same_session(&record) {
|
||||
state.blocked_generation = record.generation();
|
||||
}
|
||||
drop(state);
|
||||
let url = self.bootstrap(&check)?;
|
||||
if !url.is_empty() {
|
||||
return Ok(self.verification_required(url));
|
||||
}
|
||||
let _state = self.scope.lock().expect("signed session coordinator lock");
|
||||
let bootstrapped = self.load()?;
|
||||
if bootstrapped.usable(self.registry.auth.now())
|
||||
&& !bootstrapped.same_session(&record)
|
||||
&& session_retries < 1
|
||||
{
|
||||
session_retries += 1;
|
||||
record = bootstrapped;
|
||||
continue;
|
||||
}
|
||||
return Ok(protocol::response_value(response, self.registry.auth.now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
use crate::manifest::SignedSession;
|
||||
use aes_gcm::aead::{OsRng, rand_core::RngCore};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use spotiflac_network::{HttpResponse, query, url::UrlParts};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
pub fn defaults(mut config: SignedSession) -> SignedSession {
|
||||
for (value, fallback) in [
|
||||
(&mut config.app_version, "ext-1.0"),
|
||||
(&mut config.platform, "extension"),
|
||||
(&mut config.callback_url, "spotiflac://session-grant"),
|
||||
(&mut config.scheme_label, "SPOTIFLAC-HMAC-V1"),
|
||||
(&mut config.header_prefix, "X-Sig-"),
|
||||
(&mut config.endpoints.bootstrap, "/bootstrap"),
|
||||
(&mut config.endpoints.challenge, "/challenge"),
|
||||
(&mut config.endpoints.exchange, "/session/exchange"),
|
||||
] {
|
||||
if value.is_empty() {
|
||||
*value = fallback.into();
|
||||
}
|
||||
}
|
||||
if config.time_window_seconds <= 0 {
|
||||
config.time_window_seconds = 300;
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub fn namespace(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ".-_".contains(*ch))
|
||||
.collect::<String>()
|
||||
.trim_matches(['.', '-', '_'])
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub fn filename(config: &SignedSession) -> Result<String, String> {
|
||||
let namespace = namespace(&config.namespace);
|
||||
if namespace.is_empty() {
|
||||
return Err("signed session namespace is empty".into());
|
||||
}
|
||||
let scope = [
|
||||
namespace.as_str(),
|
||||
&config.base_url.trim().to_lowercase(),
|
||||
&config.app_version.trim().to_lowercase(),
|
||||
&config.platform.trim().to_lowercase(),
|
||||
]
|
||||
.join("\n");
|
||||
Ok(format!(
|
||||
"{namespace}-{}.json",
|
||||
&hex(&Sha256::digest(scope))[..16]
|
||||
))
|
||||
}
|
||||
|
||||
pub fn endpoint(config: &SignedSession, endpoint: &str) -> Result<String, String> {
|
||||
let base = UrlParts::parse(&(config.base_url.trim_end_matches('/').to_owned() + "/"))
|
||||
.filter(|base| base.scheme == "https" && !base.hostname.is_empty())
|
||||
.ok_or("invalid signed session baseUrl")?;
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
return Err("signed session endpoint is empty".into());
|
||||
}
|
||||
if endpoint.starts_with("https://") {
|
||||
return Ok(endpoint.to_owned());
|
||||
}
|
||||
base.resolve(endpoint.trim_start_matches('/'))
|
||||
.map(|url| url.display_url())
|
||||
.ok_or_else(|| "invalid signed session endpoint".into())
|
||||
}
|
||||
|
||||
pub fn with_query(input: &str, values: &[(&str, &str)]) -> Result<String, String> {
|
||||
let mut url = UrlParts::parse(input).ok_or("invalid signed session URL")?;
|
||||
let mut query = query::parse(&url.raw_query);
|
||||
for (key, value) in values {
|
||||
query::set(&mut query, key, value);
|
||||
}
|
||||
url.raw_query = query::encode(&query);
|
||||
Ok(url.display_url())
|
||||
}
|
||||
|
||||
pub fn challenge_url(
|
||||
config: &SignedSession,
|
||||
challenge: &str,
|
||||
state: &str,
|
||||
) -> Result<String, String> {
|
||||
let callback = with_query(
|
||||
&config.callback_url,
|
||||
&[("cb_version", "v2grant"), ("state", state)],
|
||||
)?;
|
||||
with_query(
|
||||
&endpoint(config, &config.endpoints.challenge)?,
|
||||
&[("id", challenge), ("cb", &callback)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_time(value: &str) -> Option<i128> {
|
||||
// Match Go's RFC3339 fallback parser: uppercase T/Z, one- or two-digit
|
||||
// hours, comma fractions, truncated nanoseconds, and normalized +24:60.
|
||||
fn number(value: &str, minimum: usize, maximum: usize) -> Option<u32> {
|
||||
((minimum..=maximum).contains(&value.len())
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.then(|| value.parse().ok())
|
||||
.flatten()
|
||||
}
|
||||
let (date, clock) = value.trim().split_once('T')?;
|
||||
let mut date = date.split('-');
|
||||
let year = number(date.next()?, 4, 4)? as i32;
|
||||
let month = Month::try_from(number(date.next()?, 2, 2)? as u8).ok()?;
|
||||
let day = number(date.next()?, 2, 2)? as u8;
|
||||
if date.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let zone_start = clock.find(['Z', '+', '-'])?;
|
||||
let (clock, zone) = clock.split_at(zone_start);
|
||||
let offset = if zone == "Z" {
|
||||
0
|
||||
} else {
|
||||
let (sign, zone) = zone.split_at(1);
|
||||
let (hours, minutes) = zone.split_once(':')?;
|
||||
let hours = number(hours, 2, 2)?;
|
||||
let minutes = number(minutes, 2, 2)?;
|
||||
if hours > 24 || minutes > 60 {
|
||||
return None;
|
||||
}
|
||||
i128::from(hours * 3600 + minutes * 60)
|
||||
* if sign == "-" {
|
||||
-1
|
||||
} else if sign == "+" {
|
||||
1
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut clock = clock.split(':');
|
||||
let hours = number(clock.next()?, 1, 2)? as u8;
|
||||
let minutes = number(clock.next()?, 2, 2)? as u8;
|
||||
let seconds = clock.next()?;
|
||||
if clock.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let (seconds, fraction) = seconds.split_once(['.', ',']).unwrap_or((seconds, ""));
|
||||
let seconds = number(seconds, 2, 2)? as u8;
|
||||
let nanos = if fraction.is_empty() {
|
||||
if value.contains(['.', ',']) {
|
||||
return None;
|
||||
}
|
||||
0
|
||||
} else {
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let digits = &fraction[..fraction.len().min(9)];
|
||||
digits.parse::<u32>().ok()? * 10_u32.pow(9 - digits.len() as u32)
|
||||
};
|
||||
let date = Date::from_calendar_date(year, month, day).ok()?;
|
||||
let time = Time::from_hms_nano(hours, minutes, seconds, nanos).ok()?;
|
||||
Some(
|
||||
PrimitiveDateTime::new(date, time)
|
||||
.assume_utc()
|
||||
.unix_timestamp_nanos()
|
||||
- offset * 1_000_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
fn string_or_null<'de, D: serde::Deserializer<'de>>(value: D) -> Result<String, D::Error> {
|
||||
Ok(Option::<String>::deserialize(value)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
// Go's JSON struct decoder accepts ASCII case-insensitive field names and
|
||||
// treats null scalars as their zero values. All gateway decisions use this path.
|
||||
pub(super) fn object_fields(
|
||||
bytes: &[u8],
|
||||
strings: &[&str],
|
||||
booleans: &[&str],
|
||||
integers: &[&str],
|
||||
) -> Result<serde_json::Map<String, Value>, String> {
|
||||
struct Fields<'a>([&'a [&'a str]; 3]);
|
||||
impl<'de> serde::de::Visitor<'de> for Fields<'_> {
|
||||
type Value = serde_json::Map<String, Value>;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("a signed session object")
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(serde_json::Map::new())
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> Result<Self::Value, A::Error> {
|
||||
let mut result = serde_json::Map::new();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
let key = key.to_ascii_lowercase();
|
||||
let Some(kind) = self
|
||||
.0
|
||||
.iter()
|
||||
.position(|fields| fields.contains(&key.as_str()))
|
||||
else {
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
continue;
|
||||
};
|
||||
let value = map.next_value::<Value>()?;
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
let valid = match kind {
|
||||
0 => value.is_string(),
|
||||
1 => value.is_boolean(),
|
||||
_ => value
|
||||
.as_i64()
|
||||
.is_some_and(|value| isize::try_from(value).is_ok()),
|
||||
};
|
||||
if !valid {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"invalid signed session field: {key}"
|
||||
)));
|
||||
}
|
||||
// Preserve encounter order across duplicate/case-folded keys;
|
||||
// null leaves the preceding scalar untouched, as in Go.
|
||||
result.insert(key, value);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
let mut decoder = serde_json::Deserializer::from_slice(bytes);
|
||||
let result =
|
||||
serde::Deserializer::deserialize_any(&mut decoder, Fields([strings, booleans, integers]))
|
||||
.map_err(|error| error.to_string())?;
|
||||
decoder.end().map_err(|error| error.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
pub struct Record {
|
||||
#[serde(default, deserialize_with = "string_or_null")]
|
||||
pub install_id: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub session_id: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub session_secret: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub expires_at: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub namespace: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub base_url: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub app_version: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "String::is_empty",
|
||||
deserialize_with = "string_or_null"
|
||||
)]
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
impl Drop for Record {
|
||||
fn drop(&mut self) {
|
||||
self.session_secret.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl Record {
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
|
||||
serde_json::from_value(Value::Object(object_fields(
|
||||
bytes,
|
||||
&[
|
||||
"install_id",
|
||||
"session_id",
|
||||
"session_secret",
|
||||
"expires_at",
|
||||
"namespace",
|
||||
"base_url",
|
||||
"app_version",
|
||||
"platform",
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
)?))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
pub fn usable(&self, now: i128) -> bool {
|
||||
!self.session_id.trim().is_empty()
|
||||
&& !self.session_secret.trim().is_empty()
|
||||
&& parse_time(&self.expires_at).is_none_or(|expires| now < expires)
|
||||
}
|
||||
|
||||
pub fn same_session(&self, other: &Self) -> bool {
|
||||
!self.session_id.is_empty()
|
||||
&& self.session_id == other.session_id
|
||||
&& self.session_secret == other.session_secret
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> String {
|
||||
if self.session_id.is_empty() || self.session_secret.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
hex(&Sha256::digest(
|
||||
Zeroizing::new(format!("{}\n{}", self.session_id, self.session_secret)).as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.session_id.clear();
|
||||
self.session_secret.zeroize();
|
||||
self.expires_at.clear();
|
||||
}
|
||||
|
||||
pub fn normalize_scope(&mut self, config: &SignedSession) -> bool {
|
||||
let namespace = namespace(&config.namespace);
|
||||
if self.namespace == namespace
|
||||
&& self.base_url == config.base_url.trim()
|
||||
&& self.app_version == config.app_version.trim()
|
||||
&& self.platform == config.platform.trim()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.namespace.is_empty()
|
||||
|| !self.base_url.is_empty()
|
||||
|| !self.app_version.is_empty()
|
||||
|| !self.platform.is_empty()
|
||||
{
|
||||
self.clear();
|
||||
}
|
||||
self.namespace = namespace;
|
||||
self.base_url = config.base_url.trim().to_owned();
|
||||
self.app_version = config.app_version.trim().to_owned();
|
||||
self.platform = config.platform.trim().to_owned();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn refresh_due(&self, config: &SignedSession, now: i128) -> bool {
|
||||
!config.endpoints.refresh.is_empty()
|
||||
&& self.usable(now)
|
||||
&& parse_time(&self.expires_at)
|
||||
.is_some_and(|expires| now < expires && expires - now <= 3_600_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn random_hex(count: usize) -> Result<String, String> {
|
||||
let mut bytes = Zeroizing::new(vec![0; count]);
|
||||
OsRng
|
||||
.try_fill_bytes(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(hex(&bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(output, "{byte:02x}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn hmac(key: &[u8], message: &[u8]) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(message);
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
pub fn signed_headers(
|
||||
config: &SignedSession,
|
||||
record: &Record,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: &[u8],
|
||||
now: i128,
|
||||
nonce: &str,
|
||||
) -> Result<BTreeMap<String, String>, String> {
|
||||
let url = UrlParts::parse(url).ok_or("invalid signed session URL")?;
|
||||
let time = OffsetDateTime::from_unix_timestamp_nanos(now).map_err(|error| error.to_string())?;
|
||||
let timestamp = format!(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
|
||||
time.year(),
|
||||
u8::from(time.month()),
|
||||
time.day(),
|
||||
time.hour(),
|
||||
time.minute(),
|
||||
time.second(),
|
||||
time.millisecond()
|
||||
);
|
||||
let hash = hex(&Sha256::digest(body));
|
||||
if config.time_window_seconds <= 0 {
|
||||
return Err("invalid signed session time window".into());
|
||||
}
|
||||
let window = time.unix_timestamp() / config.time_window_seconds as i64;
|
||||
let rolling = Zeroizing::new(hmac(
|
||||
record.session_secret.as_bytes(),
|
||||
format!("{window}:{}", record.session_id).as_bytes(),
|
||||
));
|
||||
let signing = [
|
||||
&config.scheme_label,
|
||||
method,
|
||||
&url.escaped_path(),
|
||||
"",
|
||||
&hash,
|
||||
×tamp,
|
||||
nonce,
|
||||
&record.session_id,
|
||||
&config.app_version,
|
||||
&config.platform,
|
||||
]
|
||||
.join("\n");
|
||||
let signature = hmac(rolling.as_bytes(), signing.as_bytes());
|
||||
Ok([
|
||||
("Session", record.session_id.clone()),
|
||||
("Timestamp", timestamp),
|
||||
("Nonce", nonce.to_owned()),
|
||||
("Body-SHA256", hash),
|
||||
("Signature", signature),
|
||||
("App-Version", config.app_version.clone()),
|
||||
("Platform", config.platform.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, value)| (format!("{}{name}", config.header_prefix), value))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ErrorContract {
|
||||
#[serde(deserialize_with = "string_or_null")]
|
||||
pub error: String,
|
||||
#[serde(deserialize_with = "string_or_null")]
|
||||
pub code: String,
|
||||
#[serde(deserialize_with = "string_or_null")]
|
||||
pub origin: String,
|
||||
#[serde(deserialize_with = "string_or_null")]
|
||||
pub action: String,
|
||||
pub retryable: bool,
|
||||
#[serde(deserialize_with = "string_or_null")]
|
||||
pub retry_mode: String,
|
||||
pub retry_after_seconds: isize,
|
||||
}
|
||||
|
||||
impl ErrorContract {
|
||||
pub fn parse(body: &[u8]) -> Self {
|
||||
let mut value: Self = object_fields(
|
||||
body,
|
||||
&["error", "code", "origin", "action", "retry_mode"],
|
||||
&["retryable"],
|
||||
&["retry_after_seconds"],
|
||||
)
|
||||
.ok()
|
||||
.and_then(|fields| serde_json::from_value(Value::Object(fields)).ok())
|
||||
.unwrap_or_default();
|
||||
value.error = value.error.trim().to_owned();
|
||||
value.code = value.code.trim().to_uppercase();
|
||||
value.origin = value.origin.trim().to_lowercase();
|
||||
value.action = value.action.trim().to_lowercase();
|
||||
value.retry_mode = value.retry_mode.trim().to_lowercase();
|
||||
value.retry_after_seconds = value.retry_after_seconds.max(0);
|
||||
value
|
||||
}
|
||||
|
||||
pub fn gateway_action(&self, status: u16) -> &str {
|
||||
match (
|
||||
status,
|
||||
self.origin.as_str(),
|
||||
self.code.as_str(),
|
||||
self.action.as_str(),
|
||||
) {
|
||||
(401, "gateway", "SESSION_INVALID", "bootstrap_session") => "bootstrap_session",
|
||||
(428, "gateway", "VERIFY_REQUIRED", "verify") => "verify",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_retry(&self, status: u16) -> bool {
|
||||
status == 503
|
||||
&& self.origin == "provider"
|
||||
&& self.code == "PROVIDER_UNAVAILABLE"
|
||||
&& self.retryable
|
||||
&& self.retry_mode == "same_operation"
|
||||
}
|
||||
|
||||
pub fn request_auth_invalid(&self, status: u16) -> bool {
|
||||
status == 403
|
||||
&& self.origin == "gateway"
|
||||
&& self.code == "REQUEST_AUTH_INVALID"
|
||||
&& self.action.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_after(value: &str, now: i128) -> i64 {
|
||||
let value = value.trim();
|
||||
if let Ok(seconds) = value.parse::<isize>() {
|
||||
return seconds.max(0) as i64;
|
||||
}
|
||||
httpdate::parse_http_date(value)
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |time| {
|
||||
((time.as_nanos() as i128 - now) / 1_000_000_000).max(0) as i64
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response_retry_after(response: &HttpResponse, now: i128) -> i64 {
|
||||
response
|
||||
.headers
|
||||
.get("Retry-After")
|
||||
.and_then(|values| values.first())
|
||||
.map_or(0, |value| retry_after(value, now))
|
||||
}
|
||||
|
||||
pub fn provider_delay(response: &HttpResponse, contract: &ErrorContract, now: i128) -> Duration {
|
||||
// Go's retry scheduler preserves the fractional part of an HTTP date and
|
||||
// parses numeric headers without trimming; the response metadata uses a
|
||||
// separate, whole-second parser above.
|
||||
if let Some(value) = response
|
||||
.headers
|
||||
.get("Retry-After")
|
||||
.and_then(|values| values.first())
|
||||
{
|
||||
let nanos = if let Ok(seconds) = value.parse::<isize>() {
|
||||
// time.Duration is an int64 in Go, including multiplication overflow.
|
||||
i128::from((seconds as i64).wrapping_mul(1_000_000_000))
|
||||
} else {
|
||||
httpdate::parse_http_date(value)
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |time| time.as_nanos() as i128 - now)
|
||||
};
|
||||
if nanos > 0 {
|
||||
return Duration::from_nanos(nanos.min(120_000_000_000) as u64);
|
||||
}
|
||||
}
|
||||
Duration::from_secs(if contract.retry_after_seconds > 0 {
|
||||
contract.retry_after_seconds.min(120) as u64
|
||||
} else {
|
||||
1
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response_value(response: HttpResponse, now: i128) -> Value {
|
||||
let contract = ErrorContract::parse(&response.body);
|
||||
let retry = response_retry_after(&response, now);
|
||||
let mut result = json!({"statusCode":response.status,"status":response.status,"ok":(200..300).contains(&response.status),
|
||||
"url":response.url,"body":crate::host::decode_go_utf8(&response.body),"headers":response.headers.into_iter().map(|(key, values)| {
|
||||
let value = if values.len() == 1 { json!(values[0]) } else { json!(values) }; (key,value)
|
||||
}).collect::<BTreeMap<_,_>>(),"retryAfterSeconds":if retry>0 {retry} else {contract.retry_after_seconds as i64}});
|
||||
if !contract.code.is_empty() || !contract.origin.is_empty() || !contract.action.is_empty() {
|
||||
for (key, value) in [
|
||||
("error", json!(contract.error)),
|
||||
("code", json!(contract.code)),
|
||||
("origin", json!(contract.origin)),
|
||||
("action", json!(contract.action)),
|
||||
("retryable", json!(contract.retryable)),
|
||||
("retryMode", json!(contract.retry_mode)),
|
||||
] {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::protocol::{self, Record};
|
||||
use crate::manifest::SignedSession;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RuntimeHints {
|
||||
default: String,
|
||||
values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RuntimeHints {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
|
||||
struct Fields;
|
||||
impl<'de> serde::de::Visitor<'de> for Fields {
|
||||
type Value = RuntimeHints;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("a runtime state object")
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(RuntimeHints::default())
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> Result<Self::Value, A::Error> {
|
||||
let mut result = RuntimeHints::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if key.eq_ignore_ascii_case("d") {
|
||||
if let Some(value) = map.next_value::<Option<String>>()? {
|
||||
result.default = value;
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("s") {
|
||||
match map.next_value::<Option<BTreeMap<String, Option<String>>>>()? {
|
||||
Some(values) => result.values.extend(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, value.unwrap_or_default())),
|
||||
),
|
||||
None => result.values.clear(),
|
||||
}
|
||||
} else {
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
decoder.deserialize_any(Fields)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeHints {
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
let mut hints: Self = serde_json::from_str(raw).unwrap_or_default();
|
||||
hints.default = normalize_hint(&hints.default);
|
||||
hints.values = hints
|
||||
.values
|
||||
.into_iter()
|
||||
.filter_map(|(path, value)| {
|
||||
let key = path
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let value = normalize_hint(&value);
|
||||
(!key.is_empty() && key != "." && !value.is_empty()).then_some((key, value))
|
||||
})
|
||||
.collect();
|
||||
hints
|
||||
}
|
||||
|
||||
fn get(&self, path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| self.values.get(name))
|
||||
.map_or(&self.default, String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_hint(value: &str) -> String {
|
||||
let value = value.trim().to_lowercase();
|
||||
if value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
value
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecordStore {
|
||||
path: PathBuf,
|
||||
config: SignedSession,
|
||||
}
|
||||
|
||||
impl RecordStore {
|
||||
pub fn open(root: &Path, config: &SignedSession) -> Result<Self, String> {
|
||||
let directory = root.join("signed_sessions");
|
||||
if directory
|
||||
.symlink_metadata()
|
||||
.is_ok_and(|metadata| !metadata.is_dir())
|
||||
{
|
||||
return Err("invalid signed session directory".into());
|
||||
}
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder
|
||||
.create(&directory)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let directory = fs::canonicalize(directory).map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
path: directory.join(protocol::filename(config)?),
|
||||
config: config.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Call while holding this scope's coordinator lock. Go stores this record
|
||||
/// as private JSON; preserve that format for the eventual ownership cutover.
|
||||
pub fn load(&self, hints: &RuntimeHints) -> Result<Record, String> {
|
||||
let mut record = match fs::symlink_metadata(&self.path) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_file() || metadata.len() > 1024 * 1024 {
|
||||
return Err("invalid signed session file".into());
|
||||
}
|
||||
let bytes =
|
||||
Zeroizing::new(fs::read(&self.path).map_err(|error| error.to_string())?);
|
||||
Record::decode(&bytes)
|
||||
.map_err(|error| format!("invalid signed session record: {error}"))?
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Record::default(),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let mut changed = false;
|
||||
if record.install_id.trim().is_empty() {
|
||||
record.install_id = hints.get(&self.path).to_owned();
|
||||
if record.install_id.is_empty() {
|
||||
record.install_id = protocol::random_hex(16)?;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if record.normalize_scope(&self.config) {
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.save(&record)?;
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn save(&self, record: &Record) -> Result<(), String> {
|
||||
let bytes =
|
||||
Zeroizing::new(serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?);
|
||||
crate::storage::atomic_write(&self.path, &bytes).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Go-compatible extension persistence. Each file is serialized across runtimes.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng, rand_core::RngCore};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use base64::Engine;
|
||||
use base64::alphabet;
|
||||
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock, RwLockReadGuard, Weak};
|
||||
use std::time::SystemTime;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("extension storage closed")]
|
||||
Closed,
|
||||
#[error("extension storage master key must be 32 base64-encoded bytes")]
|
||||
InvalidKey,
|
||||
#[error("extension storage master key is not configured")]
|
||||
MissingKey,
|
||||
#[error("invalid extension ID")]
|
||||
InvalidExtensionId,
|
||||
#[error("extension storage path must be a regular file")]
|
||||
InvalidPath,
|
||||
#[error("ciphertext too short")]
|
||||
ShortCiphertext,
|
||||
#[error("cipher: message authentication failed")]
|
||||
Authentication,
|
||||
#[error("failed to generate nonce: {0}")]
|
||||
Random(String),
|
||||
#[error("{0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("{0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("extension storage must contain a JSON object")]
|
||||
InvalidObject,
|
||||
}
|
||||
|
||||
/// The platform supplies this key. Rust never persists it or includes it in Debug.
|
||||
pub struct StorageMasterKey(Zeroizing<[u8; 32]>);
|
||||
|
||||
impl StorageMasterKey {
|
||||
pub fn from_base64(encoded: &str) -> Result<Self, StorageError> {
|
||||
let encoding = GeneralPurpose::new(
|
||||
&alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
let decoded = Zeroizing::new(
|
||||
encoding
|
||||
.decode(encoded.replace(['\r', '\n'], ""))
|
||||
.map_err(|_| StorageError::InvalidKey)?,
|
||||
);
|
||||
let key: [u8; 32] = decoded
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| StorageError::InvalidKey)?;
|
||||
Ok(Self(Zeroizing::new(key)))
|
||||
}
|
||||
|
||||
pub fn derive(&self, extension_id: &str, purpose: &str) -> Zeroizing<[u8; 32]> {
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.0.as_ref())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(b"SpotiFLAC Mobile extension storage v2\0");
|
||||
mac.update(purpose.as_bytes());
|
||||
mac.update(&[0]);
|
||||
mac.update(extension_id.as_bytes());
|
||||
Zeroizing::new(mac.finalize().into_bytes().into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Go's wire format: 12-byte nonce, ciphertext, then the 16-byte authentication tag.
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, StorageError> {
|
||||
let mut nonce = [0u8; 12];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
.map_err(|error| StorageError::Random(error.to_string()))?;
|
||||
let cipher = Aes256Gcm::new(key.into());
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce), plaintext)
|
||||
.map_err(|_| StorageError::Authentication)?;
|
||||
let mut result = Vec::with_capacity(nonce.len() + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn decrypt(ciphertext: &[u8], key: &[u8; 32]) -> Result<Zeroizing<Vec<u8>>, StorageError> {
|
||||
if ciphertext.len() < 12 {
|
||||
return Err(StorageError::ShortCiphertext);
|
||||
}
|
||||
let cipher = Aes256Gcm::new(key.into());
|
||||
let plaintext = cipher
|
||||
.decrypt(Nonce::from_slice(&ciphertext[..12]), &ciphertext[12..])
|
||||
.map_err(|_| StorageError::Authentication)?;
|
||||
Ok(Zeroizing::new(plaintext))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StoreKind {
|
||||
Storage,
|
||||
Credentials,
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl StoreKind {
|
||||
fn filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::Storage => "storage.json",
|
||||
Self::Credentials => ".credentials.enc",
|
||||
Self::Settings => "settings.enc",
|
||||
}
|
||||
}
|
||||
|
||||
fn purpose(self) -> &'static str {
|
||||
match self {
|
||||
Self::Storage => "",
|
||||
Self::Credentials => "credentials",
|
||||
Self::Settings => "settings",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct Identity {
|
||||
length: u64,
|
||||
modified: Option<SystemTime>,
|
||||
#[cfg(unix)]
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FileCache {
|
||||
snapshot: Option<Map<String, Value>>,
|
||||
identity: Option<Identity>,
|
||||
key_tag: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
type SharedFile = Arc<Mutex<FileCache>>;
|
||||
|
||||
fn shared_file(path: &Path) -> SharedFile {
|
||||
static FILES: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<FileCache>>>>> = OnceLock::new();
|
||||
let mut files = FILES
|
||||
.get_or_init(Mutex::default)
|
||||
.lock()
|
||||
.expect("extension file registry lock");
|
||||
if let Some(file) = files.get(path).and_then(Weak::upgrade) {
|
||||
return file;
|
||||
}
|
||||
files.retain(|_, file| file.strong_count() > 0);
|
||||
let file = Arc::new(Mutex::new(FileCache::default()));
|
||||
files.insert(path.to_owned(), Arc::downgrade(&file));
|
||||
file
|
||||
}
|
||||
|
||||
/// Stores for one canonical data directory. Instances share locks and immutable
|
||||
/// snapshots, while values returned to callers are independent deep copies.
|
||||
pub struct ExtensionStore {
|
||||
directory: PathBuf,
|
||||
extension_id: String,
|
||||
master_key: Option<Arc<StorageMasterKey>>,
|
||||
credentials_tag: OnceLock<Option<[u8; 32]>>,
|
||||
settings_tag: OnceLock<Option<[u8; 32]>>,
|
||||
storage: SharedFile,
|
||||
credentials: SharedFile,
|
||||
settings: SharedFile,
|
||||
salt: SharedFile,
|
||||
closed: RwLock<bool>,
|
||||
}
|
||||
|
||||
impl ExtensionStore {
|
||||
pub fn open(
|
||||
directory: &Path,
|
||||
extension_id: &str,
|
||||
master_key: Option<Arc<StorageMasterKey>>,
|
||||
) -> Result<Self, StorageError> {
|
||||
if !valid_extension_id(extension_id) {
|
||||
return Err(StorageError::InvalidExtensionId);
|
||||
}
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(directory)?;
|
||||
let directory = fs::canonicalize(directory)?;
|
||||
Ok(Self {
|
||||
storage: shared_file(&directory.join(StoreKind::Storage.filename())),
|
||||
credentials: shared_file(&directory.join(StoreKind::Credentials.filename())),
|
||||
settings: shared_file(&directory.join(StoreKind::Settings.filename())),
|
||||
salt: shared_file(&directory.join(".cred_salt")),
|
||||
directory,
|
||||
extension_id: extension_id.to_owned(),
|
||||
master_key,
|
||||
credentials_tag: OnceLock::new(),
|
||||
settings_tag: OnceLock::new(),
|
||||
closed: RwLock::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn file(&self, kind: StoreKind) -> &SharedFile {
|
||||
match kind {
|
||||
StoreKind::Storage => &self.storage,
|
||||
StoreKind::Credentials => &self.credentials,
|
||||
StoreKind::Settings => &self.settings,
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&self, kind: StoreKind) -> Result<Zeroizing<[u8; 32]>, StorageError> {
|
||||
Ok(self
|
||||
.master_key
|
||||
.as_ref()
|
||||
.ok_or(StorageError::MissingKey)?
|
||||
.derive(&self.extension_id, kind.purpose()))
|
||||
}
|
||||
|
||||
fn cache_tag(&self, kind: StoreKind) -> Option<[u8; 32]> {
|
||||
let cache = match kind {
|
||||
StoreKind::Storage => return None,
|
||||
StoreKind::Credentials => &self.credentials_tag,
|
||||
StoreKind::Settings => &self.settings_tag,
|
||||
};
|
||||
*cache.get_or_init(|| {
|
||||
self.master_key.as_ref().map(|key| {
|
||||
Sha256::digest(key.derive(&self.extension_id, kind.purpose()).as_ref()).into()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh<'a>(
|
||||
&self,
|
||||
kind: StoreKind,
|
||||
cache: &'a mut FileCache,
|
||||
) -> Result<&'a Map<String, Value>, StorageError> {
|
||||
let path = self.directory.join(kind.filename());
|
||||
let identity = file_identity(&path)?;
|
||||
let key_tag = self.cache_tag(kind);
|
||||
if cache.snapshot.is_none()
|
||||
|| cache.identity != identity
|
||||
|| cache.key_tag != key_tag
|
||||
|| (kind == StoreKind::Settings && identity.is_none())
|
||||
{
|
||||
let snapshot = match read_optional(&path)? {
|
||||
Some(data) if kind == StoreKind::Storage => parse_object(&data)?,
|
||||
Some(data) => {
|
||||
let key = self.key(kind)?;
|
||||
let plaintext = match decrypt(&data, &key) {
|
||||
Ok(plaintext) => plaintext,
|
||||
Err(error) if kind == StoreKind::Credentials => {
|
||||
let legacy = self.legacy_credentials_key()?;
|
||||
let plaintext = decrypt(&data, &legacy).map_err(|_| error)?;
|
||||
// Validate before replacing a legacy file. Corrupt data is preserved.
|
||||
parse_object(&plaintext)?;
|
||||
atomic_write(&path, &encrypt(&plaintext, &key)?)?;
|
||||
plaintext
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
parse_object(&plaintext)?
|
||||
}
|
||||
None if kind == StoreKind::Settings => self.migrate_settings(&path)?,
|
||||
None => Map::new(),
|
||||
};
|
||||
cache.identity = file_identity(&path)?;
|
||||
cache.key_tag = key_tag;
|
||||
cache.snapshot = Some(snapshot);
|
||||
}
|
||||
Ok(cache.snapshot.as_ref().expect("loaded storage snapshot"))
|
||||
}
|
||||
|
||||
fn legacy_credentials_key(&self) -> Result<Zeroizing<[u8; 32]>, StorageError> {
|
||||
let _guard = self.salt.lock().expect("extension salt lock");
|
||||
let path = self.directory.join(".cred_salt");
|
||||
let salt = match read_optional(&path)? {
|
||||
Some(salt) if salt.len() == 32 => salt,
|
||||
_ => {
|
||||
let mut salt = vec![0u8; 32];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut salt)
|
||||
.map_err(|error| StorageError::Random(error.to_string()))?;
|
||||
atomic_write(&path, &salt)?;
|
||||
salt
|
||||
}
|
||||
};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(self.extension_id.as_bytes());
|
||||
hash.update(&salt);
|
||||
Ok(Zeroizing::new(hash.finalize().into()))
|
||||
}
|
||||
|
||||
fn migrate_settings(&self, path: &Path) -> Result<Map<String, Value>, StorageError> {
|
||||
let legacy = self.directory.join("settings.json");
|
||||
let Some(data) = read_optional(&legacy)? else {
|
||||
return Ok(Map::new());
|
||||
};
|
||||
let snapshot = parse_object(&data)?;
|
||||
let key = self.key(StoreKind::Settings)?;
|
||||
atomic_write(path, &encrypt(&data, &key)?)?;
|
||||
fs::remove_file(legacy)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn get(&self, kind: StoreKind, key: &str) -> Result<Option<Value>, StorageError> {
|
||||
let _access = self.access()?;
|
||||
let mut cache = self.file(kind).lock().expect("extension storage lock");
|
||||
Ok(self.refresh(kind, &mut cache)?.get(key).cloned())
|
||||
}
|
||||
|
||||
pub fn all(&self, kind: StoreKind) -> Result<Map<String, Value>, StorageError> {
|
||||
let _access = self.access()?;
|
||||
let mut cache = self.file(kind).lock().expect("extension storage lock");
|
||||
Ok(self.refresh(kind, &mut cache)?.clone())
|
||||
}
|
||||
|
||||
pub fn set(&self, kind: StoreKind, key: &str, value: Value) -> Result<(), StorageError> {
|
||||
self.mutate(kind, |snapshot| {
|
||||
snapshot.insert(key.to_owned(), value);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove(&self, kind: StoreKind, key: &str) -> Result<(), StorageError> {
|
||||
self.mutate(kind, |snapshot| {
|
||||
let existed = snapshot.remove(key).is_some();
|
||||
// Go credentials.remove persists even if the key did not exist.
|
||||
existed || kind != StoreKind::Storage
|
||||
})
|
||||
}
|
||||
|
||||
pub fn replace(&self, kind: StoreKind, value: Map<String, Value>) -> Result<(), StorageError> {
|
||||
self.mutate(kind, |snapshot| {
|
||||
*snapshot = value;
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn mutate(
|
||||
&self,
|
||||
kind: StoreKind,
|
||||
mutate: impl FnOnce(&mut Map<String, Value>) -> bool,
|
||||
) -> Result<(), StorageError> {
|
||||
let _access = self.access()?;
|
||||
let mut cache = self.file(kind).lock().expect("extension storage lock");
|
||||
let mut snapshot = self.refresh(kind, &mut cache)?.clone();
|
||||
if !mutate(&mut snapshot) {
|
||||
return Ok(());
|
||||
}
|
||||
let data = Zeroizing::new(serde_json::to_vec(&snapshot)?);
|
||||
let path = self.directory.join(kind.filename());
|
||||
if kind == StoreKind::Storage {
|
||||
atomic_write(&path, &data)?;
|
||||
} else {
|
||||
let key = self.key(kind)?;
|
||||
atomic_write(&path, &encrypt(&data, &key)?)?;
|
||||
}
|
||||
cache.identity = file_identity(&path)?;
|
||||
cache.key_tag = self.cache_tag(kind);
|
||||
cache.snapshot = Some(snapshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn access(&self) -> Result<RwLockReadGuard<'_, bool>, StorageError> {
|
||||
let guard = self
|
||||
.closed
|
||||
.read()
|
||||
.expect("extension storage lifecycle lock");
|
||||
if *guard {
|
||||
Err(StorageError::Closed)
|
||||
} else {
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for current reads/writes, then prevent stale handles from accessing
|
||||
/// a replacement installation at the same data path.
|
||||
pub(crate) fn close(&self) {
|
||||
*self
|
||||
.closed
|
||||
.write()
|
||||
.expect("extension storage lifecycle lock") = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn valid_extension_id(id: &str) -> bool {
|
||||
(1..=128).contains(&id.len())
|
||||
&& (id.as_bytes()[0].is_ascii_lowercase() || id.as_bytes()[0].is_ascii_digit())
|
||||
&& id.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
|
||||
})
|
||||
}
|
||||
|
||||
fn file_identity(path: &Path) -> Result<Option<Identity>, StorageError> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
return Err(StorageError::InvalidPath);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Ok(Some(Identity {
|
||||
length: metadata.len(),
|
||||
modified: metadata.modified().ok(),
|
||||
#[cfg(unix)]
|
||||
inode: metadata.ino(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, StorageError> {
|
||||
if file_identity(path)?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(fs::read(path)?))
|
||||
}
|
||||
|
||||
fn parse_object(data: &[u8]) -> Result<Map<String, Value>, StorageError> {
|
||||
match serde_json::from_slice(data)? {
|
||||
Value::Object(value) => Ok(value),
|
||||
Value::Null => Ok(Map::new()),
|
||||
_ => Err(StorageError::InvalidObject),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), StorageError> {
|
||||
file_identity(path)?;
|
||||
let directory = path.parent().ok_or(StorageError::InvalidPath)?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(".spotiflac-")
|
||||
.tempfile_in(directory)?;
|
||||
file.write_all(data)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(path).map_err(|error| error.error)?;
|
||||
// Directory fsync is unavailable on some filesystems; rename remains atomic.
|
||||
if let Ok(directory) = File::open(directory) {
|
||||
let _ = directory.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn retired_store_cannot_read_or_overwrite_a_reinstalled_extension() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = Arc::new(
|
||||
StorageMasterKey::from_base64("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap(),
|
||||
);
|
||||
let old = ExtensionStore::open(
|
||||
directory.path(),
|
||||
"example.extension",
|
||||
Some(Arc::clone(&key)),
|
||||
)
|
||||
.unwrap();
|
||||
old.set(StoreKind::Storage, "old", true.into()).unwrap();
|
||||
old.close();
|
||||
fs::remove_file(directory.path().join("storage.json")).unwrap();
|
||||
let replacement =
|
||||
ExtensionStore::open(directory.path(), "example.extension", Some(key)).unwrap();
|
||||
for kind in [
|
||||
StoreKind::Storage,
|
||||
StoreKind::Credentials,
|
||||
StoreKind::Settings,
|
||||
] {
|
||||
replacement.set(kind, "new", true.into()).unwrap();
|
||||
assert!(matches!(old.get(kind, "new"), Err(StorageError::Closed)));
|
||||
assert!(matches!(old.all(kind), Err(StorageError::Closed)));
|
||||
assert!(matches!(
|
||||
old.set(kind, "stale", true.into()),
|
||||
Err(StorageError::Closed)
|
||||
));
|
||||
assert!(matches!(old.remove(kind, "new"), Err(StorageError::Closed)));
|
||||
assert_eq!(
|
||||
replacement.all(kind).unwrap(),
|
||||
Map::from_iter([("new".into(), true.into())])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_snapshots_are_scoped_to_master_key() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = Arc::new(
|
||||
StorageMasterKey::from_base64("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap(),
|
||||
);
|
||||
let other_key = Arc::new(
|
||||
StorageMasterKey::from_base64("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=").unwrap(),
|
||||
);
|
||||
let store = ExtensionStore::open(
|
||||
directory.path(),
|
||||
"example.extension",
|
||||
Some(Arc::clone(&key)),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.set(StoreKind::Credentials, "token", "secret".into())
|
||||
.unwrap();
|
||||
store
|
||||
.set(StoreKind::Settings, "theme", "dark".into())
|
||||
.unwrap();
|
||||
|
||||
let other =
|
||||
ExtensionStore::open(directory.path(), "example.extension", Some(other_key)).unwrap();
|
||||
for kind in [StoreKind::Credentials, StoreKind::Settings] {
|
||||
assert!(matches!(other.all(kind), Err(StorageError::Authentication)));
|
||||
}
|
||||
|
||||
let missing = ExtensionStore::open(directory.path(), "example.extension", None).unwrap();
|
||||
for kind in [StoreKind::Credentials, StoreKind::Settings] {
|
||||
assert!(matches!(missing.all(kind), Err(StorageError::MissingKey)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Manifest-declared download policy. Execution belongs to the download manager.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadTransferPolicy {
|
||||
pub max_attempts: i64,
|
||||
pub initial_retry_delay_ms: i64,
|
||||
pub max_retry_delay_ms: i64,
|
||||
pub resume_policy: String,
|
||||
pub persistent_checkpoint: bool,
|
||||
pub refresh_stream_on_status: BTreeSet<i64>,
|
||||
pub max_parallel_segments: i64,
|
||||
pub max_concurrent_downloads: i64,
|
||||
}
|
||||
|
||||
impl Default for DownloadTransferPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 3,
|
||||
initial_retry_delay_ms: 500,
|
||||
max_retry_delay_ms: 8000,
|
||||
resume_policy: "none".to_owned(),
|
||||
persistent_checkpoint: false,
|
||||
refresh_stream_on_status: BTreeSet::from([401, 403]),
|
||||
max_parallel_segments: 3,
|
||||
max_concurrent_downloads: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn number(value: Option<&Value>, fallback: i64) -> i64 {
|
||||
value.and_then(Value::as_f64).map_or(fallback, rounded_int)
|
||||
}
|
||||
|
||||
pub(crate) fn rounded_int(value: f64) -> i64 {
|
||||
let rounded = value.round();
|
||||
// Go uses the target's conversion: ARM saturates, while x86 returns
|
||||
// MinInt on overflow. Keep the native Go int width on ARM32 as well.
|
||||
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
|
||||
if rounded >= isize::MAX as f64 || rounded < isize::MIN as f64 {
|
||||
return isize::MIN as i64;
|
||||
}
|
||||
(rounded as isize) as i64
|
||||
}
|
||||
|
||||
impl DownloadTransferPolicy {
|
||||
pub fn from_capabilities(capabilities: &Map<String, Value>) -> Self {
|
||||
let mut policy = Self::default();
|
||||
let Some(config) = capabilities
|
||||
.get("downloadTransfer")
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return policy;
|
||||
};
|
||||
policy.max_attempts = number(config.get("maxAttempts"), policy.max_attempts).clamp(1, 8);
|
||||
policy.initial_retry_delay_ms = number(
|
||||
config.get("initialRetryDelayMs"),
|
||||
policy.initial_retry_delay_ms,
|
||||
)
|
||||
.clamp(100, 30_000);
|
||||
policy.max_retry_delay_ms =
|
||||
number(config.get("maxRetryDelayMs"), policy.max_retry_delay_ms)
|
||||
.clamp(policy.initial_retry_delay_ms, 120_000);
|
||||
if let Some(resume) = config.get("resumePolicy").and_then(Value::as_str) {
|
||||
let resume = resume.trim().to_lowercase();
|
||||
if matches!(resume.as_str(), "none" | "validated") {
|
||||
policy.resume_policy = resume;
|
||||
}
|
||||
}
|
||||
policy.persistent_checkpoint = config.get("persistentCheckpoint")
|
||||
== Some(&Value::Bool(true))
|
||||
&& policy.resume_policy == "validated";
|
||||
if let Some(statuses) = config
|
||||
.get("refreshStreamOnStatus")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
let statuses: BTreeSet<_> = statuses
|
||||
.iter()
|
||||
.map(|value| number(Some(value), 0))
|
||||
.filter(|value| (400..=599).contains(value))
|
||||
.collect();
|
||||
if !statuses.is_empty() {
|
||||
policy.refresh_stream_on_status = statuses;
|
||||
}
|
||||
}
|
||||
policy.max_parallel_segments = number(
|
||||
config.get("maxParallelSegments"),
|
||||
policy.max_parallel_segments,
|
||||
)
|
||||
.clamp(1, 8);
|
||||
policy.max_concurrent_downloads = number(
|
||||
config.get("maxConcurrentDownloads"),
|
||||
policy.max_concurrent_downloads,
|
||||
)
|
||||
.clamp(1, 3);
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate(capabilities: &Map<String, Value>) -> Result<(), String> {
|
||||
let Some(raw) = capabilities.get("downloadTransfer") else {
|
||||
return Ok(());
|
||||
};
|
||||
let config = raw.as_object().ok_or("must be an object")?;
|
||||
if let Some(resume) = config.get("resumePolicy")
|
||||
&& !matches!(resume.as_str(), Some("none" | "validated"))
|
||||
{
|
||||
return Err("resumePolicy must be 'none' or 'validated'".to_owned());
|
||||
}
|
||||
if let Some(checkpoint) = config.get("persistentCheckpoint")
|
||||
&& !checkpoint.is_boolean()
|
||||
{
|
||||
return Err("persistentCheckpoint must be a boolean".to_owned());
|
||||
}
|
||||
for key in [
|
||||
"maxAttempts",
|
||||
"initialRetryDelayMs",
|
||||
"maxRetryDelayMs",
|
||||
"maxParallelSegments",
|
||||
"maxConcurrentDownloads",
|
||||
] {
|
||||
if let Some(value) = config.get(key)
|
||||
&& number(Some(value), -1) < 0
|
||||
{
|
||||
return Err(format!("{key} must be a non-negative number"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::runtime::{Control, ExtensionServices};
|
||||
use rquickjs::{Ctx, Function, Object, Value};
|
||||
use spotiflac_core::matching;
|
||||
pub(crate) use spotiflac_network::random_user_agent;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn register<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
host: &Object<'js>,
|
||||
control: Arc<Control>,
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<()> {
|
||||
let logs = Arc::clone(&services.logs);
|
||||
let id = services.extension_id.clone();
|
||||
host.set(
|
||||
"managedConsole",
|
||||
services.load_mode != crate::runtime::LoadMode::Initialize,
|
||||
)?;
|
||||
let auth = services.auth_registry.clone();
|
||||
let provider_id = services.extension_id.clone();
|
||||
host.set(
|
||||
"providerPendingVerification",
|
||||
Function::new(ctx.clone(), move || {
|
||||
auth.as_ref()
|
||||
.filter(|auth| auth.has_fresh_challenge(&provider_id))
|
||||
.map(|_| provider_id.clone())
|
||||
})?,
|
||||
)?;
|
||||
let progress = Arc::clone(&services.downloads.progress);
|
||||
let progress_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"providerProgress",
|
||||
Function::new(ctx.clone(), move |percent: i32| {
|
||||
let id = progress_control.item_id();
|
||||
if !id.is_empty() && progress_control.check().is_ok() {
|
||||
let _ = progress.set_progress(&id, f64::from(percent) / 100.0, 0, 0);
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"providerInteger",
|
||||
Function::new(ctx.clone(), crate::provider::integer)?,
|
||||
)?;
|
||||
host.set(
|
||||
"providerTrim",
|
||||
Function::new(ctx.clone(), |value: String| value.trim().to_owned())?,
|
||||
)?;
|
||||
host.set(
|
||||
"providerAudioTraits",
|
||||
Function::new(ctx.clone(), crate::provider::audio_traits)?,
|
||||
)?;
|
||||
host.set(
|
||||
"extensionLog",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |level: String, values: Vec<String>, count: usize| {
|
||||
logs.extension(&id, &level, values, count)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
host.set(
|
||||
"logOpaqueType",
|
||||
Function::new(ctx.clone(), |value: Value<'js>| {
|
||||
if value.is_proxy() {
|
||||
"<goja.Proxy>"
|
||||
} else if value.is_promise() {
|
||||
"<*goja.Promise>"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
let comparison = Arc::clone(&control);
|
||||
host.set(
|
||||
"compareStrings",
|
||||
Function::new(ctx.clone(), move |first: String, second: String| {
|
||||
// The call boundary turns cancellation/timeout into its typed error.
|
||||
matching::compare_strings(&first, &second, &|| {
|
||||
comparison.check().map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"normalizeMatching",
|
||||
Function::new(ctx.clone(), |value: String| matching::normalize(&value))?,
|
||||
)?;
|
||||
host.set(
|
||||
"compareDuration",
|
||||
Function::new(ctx.clone(), |first: f64, second: f64, tolerance: f64| {
|
||||
// Goja's ToInteger clips infinities and overflow to the i64 endpoints.
|
||||
matching::compare_duration(first as i64, second as i64, tolerance as i64)
|
||||
})?,
|
||||
)?;
|
||||
let downloads = Arc::clone(&services.downloads);
|
||||
let download_control = Arc::clone(&control);
|
||||
host.set(
|
||||
"downloadCancelled",
|
||||
Function::new(ctx.clone(), move || {
|
||||
let id = download_control.item_id();
|
||||
!id.is_empty() && downloads.cancellation.is_cancelled(&id).unwrap_or(false)
|
||||
})?,
|
||||
)?;
|
||||
host.set(
|
||||
"requestCancelled",
|
||||
Function::new(ctx.clone(), move || control.request_cancelled())?,
|
||||
)?;
|
||||
let version = services.app_version.clone();
|
||||
host.set(
|
||||
"appVersion",
|
||||
Function::new(ctx.clone(), move || version.get())?,
|
||||
)?;
|
||||
let version = services.app_version.clone();
|
||||
host.set(
|
||||
"appUserAgent",
|
||||
Function::new(ctx.clone(), move || version.user_agent())?,
|
||||
)?;
|
||||
host.set(
|
||||
"randomUserAgent",
|
||||
Function::new(ctx.clone(), random_user_agent)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user