aboutsummaryrefslogtreecommitdiffstats
path: root/src/copyparty
diff options
context:
space:
mode:
Diffstat (limited to 'src/copyparty')
-rw-r--r--src/copyparty/mod.rs267
1 files changed, 267 insertions, 0 deletions
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
new file mode 100644
index 0000000..00bfd53
--- /dev/null
+++ b/src/copyparty/mod.rs
@@ -0,0 +1,267 @@
1use reqwest::Client;
2use serde::Deserialize;
3use tracing::warn;
4
5use crate::error::Result;
6use crate::lang;
7
8#[derive(Clone)]
9pub struct CopypartyClient {
10 pub base_url: String,
11 pub upload_url: String,
12 pub password: Option<String>,
13 client: Client,
14}
15
16/// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`).
17pub fn percent_decode(s: &str) -> String {
18 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
19 let bytes = s.as_bytes();
20 let mut i = 0;
21 while i < bytes.len() {
22 let b = bytes[i];
23 if b == b'%' && i + 2 < bytes.len() {
24 if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
25 buf.push(hi << 4 | lo);
26 i += 3;
27 continue;
28 }
29 }
30 if b == b'+' {
31 buf.push(b' ');
32 } else {
33 buf.push(b);
34 }
35 i += 1;
36 }
37 String::from_utf8_lossy(&buf).into_owned()
38}
39
40/// Decode common HTML entities in a display string.
41/// Handles named (`&amp;`, `&lt;`) and numeric (`&#123;`, `&#x7B;`) forms.
42/// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8.
43pub fn html_decode(s: &str) -> String {
44 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
45 let bytes = s.as_bytes();
46 let mut i = 0;
47 while i < bytes.len() {
48 if bytes[i] == b'&' {
49 let rest = &s[i..];
50 if let Some(end) = rest.find(';') {
51 let entity = &rest[..=end];
52 match entity {
53 "&amp;" => buf.extend_from_slice(b"&"),
54 "&lt;" => buf.extend_from_slice(b"<"),
55 "&gt;" => buf.extend_from_slice(b">"),
56 "&quot;" => buf.extend_from_slice(b"\""),
57 "&apos;" => buf.extend_from_slice(b"'"),
58 "&nbsp;" => buf.extend_from_slice(b" "),
59 _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => {
60 let (num_str, radix) =
61 if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' {
62 (&entity[3..entity.len() - 1], 16u32)
63 } else {
64 (&entity[2..entity.len() - 1], 10u32)
65 };
66 let decoded = u32::from_str_radix(num_str, radix)
67 .ok()
68 .and_then(std::char::from_u32);
69 if let Some(c) = decoded {
70 let mut tmp = [0u8; 4];
71 buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
72 i += entity.len();
73 continue;
74 }
75 buf.extend_from_slice(entity.as_bytes());
76 }
77 _ => buf.extend_from_slice(entity.as_bytes()),
78 }
79 i += entity.len();
80 continue;
81 }
82 }
83 buf.push(bytes[i]);
84 i += 1;
85 }
86 String::from_utf8_lossy(&buf).into_owned()
87}
88
89fn hex_nibble(b: u8) -> Option<u8> {
90 match b {
91 b'0'..=b'9' => Some(b - b'0'),
92 b'A'..=b'F' => Some(b - b'A' + 10),
93 b'a'..=b'f' => Some(b - b'a' + 10),
94 _ => None,
95 }
96}
97
98/// A search result from copyparty's `/?srch` API.
99///
100/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`).
101/// The `name` / `url` / `path` fields are typically only present in directory
102/// listing (`?ls`) responses, not in search. Use the convenience accessors
103/// below instead of reading fields directly.
104#[derive(Debug, Clone, Deserialize)]
105pub struct SearchHit {
106 /// Relative path including filename (e.g. `/music/foo.mp3`).
107 /// This is the primary field populated by `/?srch`.
108 #[serde(default)]
109 pub rp: Option<String>,
110 /// Path component (directory listing only)
111 #[serde(default)]
112 pub path: Option<String>,
113 /// URL fragment for building a download URL
114 #[serde(default)]
115 pub url: Option<String>,
116 /// Bare filename (directory listing only)
117 #[serde(default)]
118 pub name: Option<String>,
119}
120
121impl SearchHit {
122 /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail.
123 pub fn filename(&self) -> Option<&str> {
124 self.name
125 .as_deref()
126 .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next()))
127 .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next()))
128 }
129
130 /// Best-effort URL path for building a full download URL.
131 /// Tries `url`, then `rp`.
132 pub fn url_path(&self) -> Option<&str> {
133 self.url.as_deref().or_else(|| self.rp.as_deref())
134 }
135}
136
137#[derive(Debug, Deserialize)]
138pub struct UploadResponse {
139 pub fileurl: Option<String>,
140}
141
142impl CopypartyClient {
143 pub fn new(base_url: String, upload_url: String, password: Option<String>) -> Self {
144 Self {
145 base_url,
146 upload_url,
147 password,
148 client: Client::new(),
149 }
150 }
151
152 /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD`
153 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`)
154 /// or relative (`music/foo.mp3`).
155 pub fn build_url(&self, path: &str) -> String {
156 let prefixed = if path.starts_with('/') {
157 format!("{}{}", self.base_url, path)
158 } else {
159 format!("{}/{}", self.base_url, path)
160 };
161 if let Some(pw) = &self.password {
162 format!("{prefixed}?pw={pw}")
163 } else {
164 prefixed
165 }
166 }
167
168 /// Search copyparty via the jPOST `/?srch` endpoint.
169 ///
170 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python
171 /// `_extract_files_from_response` helper), because the exact JSON shape can vary
172 /// across copyparty versions.
173 pub async fn search(
174 &self,
175 query: &str,
176 max_results: u32,
177 ext_filter: &[&str],
178 ) -> Result<Vec<SearchHit>> {
179 let tokens: Vec<&str> = query.split_whitespace().collect();
180 let conditions: Vec<String> = tokens
181 .iter()
182 .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")"))
183 .collect();
184
185 let mut q = conditions.join(" and ");
186 if !ext_filter.is_empty() {
187 let ext_conditions: Vec<String> = ext_filter
188 .iter()
189 .map(|e| format!("name like \"*.{e}\""))
190 .collect();
191 let ext_q = ext_conditions.join(" or ");
192 // When query has no tokens (e.g. empty autocomplete partial),
193 // avoid wrapping an empty string in parens → "() and ..." is invalid.
194 if q.is_empty() {
195 q = ext_q;
196 } else {
197 q = format!("({q}) and ({ext_q})");
198 }
199 }
200
201 let payload = serde_json::json!({"n": max_results, "q": q});
202
203 let mut req = self
204 .client
205 .post(format!("{}/?srch", self.base_url))
206 .json(&payload);
207 if let Some(pw) = &self.password {
208 req = req.header("PW", pw);
209 }
210
211 let resp = req.send().await?;
212 let status = resp.status();
213 let body_text = resp.text().await?;
214
215 if !status.is_success() {
216 let snippet: String = body_text.chars().take(200).collect();
217 return Err(crate::error::Error::Custom(lang::fmt_cp_search_http(
218 &status, &snippet,
219 )));
220 }
221
222 let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
223 let snippet: String = body_text.chars().take(200).collect();
224 crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet))
225 })?;
226
227 // Extract hits array – copyparty wraps results in {"hits": [...]}
228 let hits_val = match &root {
229 serde_json::Value::Object(map) => map.get("hits"),
230 _ => None,
231 };
232
233 let hits: Vec<SearchHit> = match hits_val {
234 Some(serde_json::Value::Array(arr)) => arr
235 .iter()
236 .filter_map(|v| SearchHit::deserialize(v).ok())
237 .collect(),
238 _ => {
239 warn!(
240 "copyparty search response missing 'hits' array; body starts: {:?}",
241 body_text.chars().take(100).collect::<String>()
242 );
243 Vec::new()
244 }
245 };
246
247 Ok(hits)
248 }
249
250 pub async fn upload(&self, file_name: &str, data: Vec<u8>) -> Result<String> {
251 let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string());
252 let form = reqwest::multipart::Form::new().part("f", part);
253
254 let mut req = self
255 .client
256 .post(format!("{}/?j=1&ck=b2", self.upload_url))
257 .multipart(form);
258
259 if let Some(pw) = &self.password {
260 req = req.header("Cookie", format!("cppwd={pw}"));
261 }
262
263 let resp: UploadResponse = req.send().await?.json().await?;
264 resp.fileurl
265 .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into()))
266 }
267}