use std::time::Duration; use reqwest::Client; use serde::Deserialize; use tracing::warn; use crate::error::Result; use crate::lang; /// Timeout for copyparty search requests; autocomplete has a 3s Discord budget, /// so this must be comfortably lower. const SEARCH_HTTP_TIMEOUT: Duration = Duration::from_secs(4); #[derive(Clone)] pub struct CopypartyClient { pub base_url: String, pub upload_url: String, pub password: Option, client: Client, } /// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`). pub fn percent_decode(s: &str) -> String { let mut buf: Vec = Vec::with_capacity(s.len()); let bytes = s.as_bytes(); let mut i = 0; while i < bytes.len() { let b = bytes[i]; if b == b'%' && i + 2 < bytes.len() && let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { buf.push(hi << 4 | lo); i += 3; continue; } if b == b'+' { buf.push(b' '); } else { buf.push(b); } i += 1; } String::from_utf8_lossy(&buf).into_owned() } /// Decode common HTML entities in a display string. /// Handles named (`&`, `<`) and numeric (`{`, `{`) forms. /// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8. pub fn html_decode(s: &str) -> String { let mut buf: Vec = Vec::with_capacity(s.len()); let bytes = s.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'&' { let rest = &s[i..]; if let Some(end) = rest.find(';') { let entity = &rest[..=end]; match entity { "&" => buf.extend_from_slice(b"&"), "<" => buf.extend_from_slice(b"<"), ">" => buf.extend_from_slice(b">"), """ => buf.extend_from_slice(b"\""), "'" => buf.extend_from_slice(b"'"), " " => buf.extend_from_slice(b" "), _ if entity.len() > 3 && entity.as_bytes()[1] == b'#' => { let (num_str, radix) = if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' { (&entity[3..entity.len() - 1], 16u32) } else { (&entity[2..entity.len() - 1], 10u32) }; let decoded = u32::from_str_radix(num_str, radix) .ok() .and_then(std::char::from_u32); if let Some(c) = decoded { let mut tmp = [0u8; 4]; buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes()); i += entity.len(); continue; } buf.extend_from_slice(entity.as_bytes()); } _ => buf.extend_from_slice(entity.as_bytes()), } i += entity.len(); continue; } } buf.push(bytes[i]); i += 1; } String::from_utf8_lossy(&buf).into_owned() } fn hex_nibble(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), b'A'..=b'F' => Some(b - b'A' + 10), b'a'..=b'f' => Some(b - b'a' + 10), _ => None, } } /// A search result from copyparty's `/?srch` API. /// /// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). /// The `name` / `url` / `path` fields are typically only present in directory /// listing (`?ls`) responses, not in search. Use the convenience accessors /// below instead of reading fields directly. #[derive(Debug, Clone, Deserialize)] pub struct SearchHit { /// Relative path including filename (e.g. `/music/foo.mp3`). /// This is the primary field populated by `/?srch`. #[serde(default)] pub rp: Option, /// Path component (directory listing only) #[serde(default)] pub path: Option, /// URL fragment for building a download URL #[serde(default)] pub url: Option, /// Bare filename (directory listing only) #[serde(default)] pub name: Option, } impl SearchHit { /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail. pub fn filename(&self) -> Option<&str> { self.name .as_deref() .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next())) .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next())) } /// Best-effort URL path for building a full download URL. /// Tries `url`, then `rp`. pub fn url_path(&self) -> Option<&str> { self.url.as_deref().or(self.rp.as_deref()) } } #[derive(Debug, Deserialize)] pub struct UploadResponse { pub fileurl: Option, } impl CopypartyClient { pub fn new(base_url: String, upload_url: String, password: Option) -> Self { Self { base_url, upload_url, password, client: Client::new(), } } /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD` /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) /// or relative (`music/foo.mp3`). pub fn build_url(&self, path: &str) -> String { let prefixed = self.build_url_no_pw(path); if let Some(pw) = &self.password { format!("{prefixed}?pw={pw}") } else { prefixed } } /// Build full URL without the password parameter. pub fn build_url_no_pw(&self, path: &str) -> String { if path.starts_with('/') { format!("{}{}", self.base_url, path) } else { format!("{}/{}", self.base_url, path) } } /// Search copyparty via the jPOST `/?srch` endpoint. /// /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python /// `_extract_files_from_response` helper), because the exact JSON shape can vary /// across copyparty versions. pub async fn search( &self, query: &str, max_results: u32, ext_filter: &[&str], ) -> Result> { let tokens: Vec<&str> = query.split_whitespace().collect(); let conditions: Vec = tokens .iter() .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")")) .collect(); let mut q = conditions.join(" and "); if !ext_filter.is_empty() { let ext_conditions: Vec = ext_filter .iter() .map(|e| format!("name like \"*.{e}\"")) .collect(); let ext_q = ext_conditions.join(" or "); // When query has no tokens (e.g. empty autocomplete partial), // avoid wrapping an empty string in parens → "() and ..." is invalid. if q.is_empty() { q = ext_q; } else { q = format!("({q}) and ({ext_q})"); } } let payload = serde_json::json!({"n": max_results, "q": q}); let mut req = self .client .post(format!("{}/?srch", self.base_url)) .json(&payload) .timeout(SEARCH_HTTP_TIMEOUT); if let Some(pw) = &self.password { req = req.header("PW", pw); } let resp = req.send().await?; let status = resp.status(); let body_text = resp.text().await?; if !status.is_success() { let snippet: String = body_text.chars().take(200).collect(); return Err(crate::error::Error::Custom(lang::fmt_cp_search_http( &status, &snippet, ))); } let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { let snippet: String = body_text.chars().take(200).collect(); crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet)) })?; // Extract hits array – copyparty wraps results in {"hits": [...]} let hits_val = match &root { serde_json::Value::Object(map) => map.get("hits"), _ => None, }; let hits: Vec = match hits_val { Some(serde_json::Value::Array(arr)) => arr .iter() .filter_map(|v| SearchHit::deserialize(v).ok()) .collect(), _ => { warn!( "copyparty search response missing 'hits' array; body starts: {:?}", body_text.chars().take(100).collect::() ); Vec::new() } }; Ok(hits) } pub async fn upload(&self, file_name: &str, data: Vec) -> Result { let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string()); let form = reqwest::multipart::Form::new().part("f", part); let mut req = self .client .post(format!("{}/?j=1&ck=b2", self.upload_url)) .multipart(form); if let Some(pw) = &self.password { req = req.header("Cookie", format!("cppwd={pw}")); } let resp: UploadResponse = req.send().await?.json().await?; resp.fileurl .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into())) } }