1use futures_util::StreamExt;
27use std::fmt;
28use std::io::{Read, Write};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
31use std::sync::{Arc, Mutex};
32use std::time::{Duration, Instant};
33use tokio::io::AsyncWriteExt;
34use tokio_util::io::ReaderStream;
35
36#[derive(Debug, Clone)]
38pub struct TransferProgress {
39 pub file_name: String,
40 pub bytes: u64,
41 pub total_bytes: Option<u64>,
42 pub speed_bps: f64,
43 pub percent: f32,
44 pub done: bool,
45 pub failed: bool,
46}
47
48#[derive(Debug, Clone)]
51pub struct TransferError {
52 message: String,
53 http_status: Option<u16>,
54 url: Option<String>,
55 path: Option<PathBuf>,
56 cancelled: bool,
57}
58
59impl TransferError {
60 pub fn new(message: impl Into<String>) -> Self {
61 Self {
62 message: message.into(),
63 http_status: None,
64 url: None,
65 path: None,
66 cancelled: false,
67 }
68 }
69
70 pub fn cancelled(message: impl Into<String>) -> Self {
71 Self {
72 cancelled: true,
73 ..Self::new(message)
74 }
75 }
76
77 pub fn with_http_status(mut self, status: u16) -> Self {
78 self.http_status = Some(status);
79 self
80 }
81
82 pub fn with_url(mut self, url: impl Into<String>) -> Self {
83 self.url = Some(url.into());
84 self
85 }
86
87 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
88 self.path = Some(path.into());
89 self
90 }
91
92 pub fn http_status(&self) -> Option<u16> {
93 self.http_status
94 }
95
96 pub fn url(&self) -> Option<&str> {
97 self.url.as_deref()
98 }
99
100 pub fn path(&self) -> Option<&Path> {
101 self.path.as_deref()
102 }
103
104 pub fn is_cancelled(&self) -> bool {
105 self.cancelled
106 }
107}
108
109impl fmt::Display for TransferError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 write!(f, "{}", self.message)
112 }
113}
114
115impl std::error::Error for TransferError {}
116
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119struct PartialDownloadMeta {
120 file_url: String,
121 remote_file_name: String,
122 updated_at: String,
123}
124
125pub fn part_path(final_path: &Path) -> PathBuf {
129 let mut p = final_path.to_path_buf();
130 let name = format!(
131 "{}.part",
132 final_path.file_name().unwrap_or_default().to_string_lossy()
133 );
134 p.set_file_name(name);
135 p
136}
137
138pub fn meta_path(part_path: &Path) -> PathBuf {
140 let mut p = part_path.to_path_buf();
141 p.set_file_name(format!(
142 "{}.meta.json",
143 part_path.file_name().unwrap_or_default().to_string_lossy()
144 ));
145 p
146}
147
148fn now_timestamp() -> String {
149 use std::time::{SystemTime, UNIX_EPOCH};
150 match SystemTime::now().duration_since(UNIX_EPOCH) {
151 Ok(d) => format!("{}.{:03}", d.as_secs(), d.subsec_millis()),
152 Err(_) => "0.000".to_string(),
153 }
154}
155
156async fn save_partial_download_meta_async(
157 meta_path: &Path,
158 meta: &PartialDownloadMeta,
159) -> Result<(), TransferError> {
160 if let Some(parent) = meta_path.parent() {
161 tokio::fs::create_dir_all(parent).await.map_err(|e| {
162 TransferError::new(format!("create partial metadata directory failed: {}", e))
163 .with_path(parent)
164 })?;
165 }
166 let content = serde_json::to_string_pretty(meta).map_err(|e| {
167 TransferError::new(format!("serialize partial metadata failed: {}", e)).with_path(meta_path)
168 })?;
169 tokio::fs::write(meta_path, content).await.map_err(|e| {
170 TransferError::new(format!("write partial metadata failed: {}", e)).with_path(meta_path)
171 })?;
172 Ok(())
173}
174
175async fn get_resume_info_async(final_path: &Path, file_url: &str) -> u64 {
176 let part = part_path(final_path);
177 let meta = meta_path(&part);
178
179 let part_size = match tokio::fs::metadata(&part).await {
180 Ok(metadata) => metadata.len(),
181 Err(_) => return 0,
182 };
183 if part_size == 0 {
184 return 0;
185 }
186
187 let can_resume = match tokio::fs::read_to_string(&meta).await {
188 Ok(content) => match serde_json::from_str::<PartialDownloadMeta>(&content) {
189 Ok(pm) => pm.file_url == file_url,
190 Err(_) => true,
191 },
192 Err(_) => true,
193 };
194
195 if can_resume {
196 part_size
197 } else {
198 let _ = tokio::fs::remove_file(&part).await;
199 let _ = tokio::fs::remove_file(&meta).await;
200 0
201 }
202}
203
204fn file_stem(name: &str) -> String {
208 Path::new(name)
209 .file_stem()
210 .and_then(|s| s.to_str())
211 .unwrap_or("output")
212 .to_string()
213}
214
215pub fn build_local_name(original_name: &str, remote_name: &str) -> String {
226 let original_stem = sanitize_name(&file_stem(original_name));
227
228 if let Some(underscore_pos) = remote_name.rfind('_') {
230 let suffix_and_ext = &remote_name[underscore_pos + 1..];
231 format!("{}_{}", original_stem, suffix_and_ext)
232 } else {
233 remote_name.to_string()
235 }
236}
237
238fn sanitize_name(value: &str) -> String {
240 let mut out = String::with_capacity(value.len());
241 for ch in value.chars() {
242 let bad = matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*');
243 if bad || ch.is_control() {
244 out.push('_');
245 } else {
246 out.push(ch);
247 }
248 }
249 let trimmed = out.trim().trim_matches('.').trim_matches('_').to_string();
250 if trimmed.is_empty() {
251 "track".to_string()
252 } else {
253 trimmed
254 }
255}
256
257fn upload_error_message(status_code: u16, body_text: &str) -> String {
260 let msg = serde_json::from_str::<serde_json::Value>(body_text)
261 .ok()
262 .and_then(|v| {
263 v.get("errors")
264 .and_then(|e| e.as_array())
265 .map(|a| {
266 a.iter()
267 .filter_map(|v| v.as_str())
268 .collect::<Vec<_>>()
269 .join(", ")
270 })
271 .or_else(|| {
272 v.get("message")
273 .and_then(|v| v.as_str())
274 .map(|s| s.to_string())
275 })
276 })
277 .unwrap_or_else(|| body_text.to_string());
278 format!("HTTP {} - {}", status_code, msg)
279}
280
281fn extract_upload_hash(body_text: &str) -> Option<String> {
282 let body = serde_json::from_str::<serde_json::Value>(body_text).ok()?;
283 body.get("hash")
284 .or_else(|| body.get("data").and_then(|d| d.get("hash")))
285 .or_else(|| body.get("task_hash"))
286 .and_then(|v| v.as_str())
287 .map(|s| s.to_string())
288}
289
290struct ProgressEmit {
291 file_name: String,
292 bytes: u64,
293 total_bytes: Option<u64>,
294 started: Instant,
295 session_bytes: u64,
296 done: bool,
297 failed: bool,
298}
299
300fn emit_progress(cb: &(dyn Fn(TransferProgress) + Send + Sync), event: ProgressEmit) {
301 let elapsed = event.started.elapsed().as_secs_f64().max(0.001);
302 let speed_bps = if event.done {
303 0.0
304 } else {
305 event.session_bytes as f64 / elapsed
306 };
307 let percent = event
308 .total_bytes
309 .map(|total| {
310 let total = total.max(1);
311 (event.bytes as f32 / total as f32) * 100.0
312 })
313 .unwrap_or(0.0);
314 cb(TransferProgress {
315 file_name: event.file_name,
316 bytes: event.bytes,
317 total_bytes: event.total_bytes,
318 speed_bps,
319 percent: if event.done && !event.failed && event.total_bytes.is_some() {
320 100.0
321 } else {
322 percent
323 },
324 done: event.done,
325 failed: event.failed,
326 });
327}
328
329pub async fn upload_file_async(
334 client: &reqwest::Client,
335 url: &str,
336 file_path: &Path,
337 extra_fields: Vec<(String, String)>,
338 cancel_token: Option<Arc<AtomicBool>>,
339 progress_cb: impl Fn(TransferProgress) + Send + Sync + 'static,
340) -> Result<String, TransferError> {
341 let file_name = file_path
342 .file_name()
343 .and_then(|n| n.to_str())
344 .unwrap_or("audio.wav")
345 .to_string();
346 let file = tokio::fs::File::open(file_path).await.map_err(|e| {
347 TransferError::new(format!("open upload file failed: {}", e)).with_path(file_path)
348 })?;
349 let file_size = file
350 .metadata()
351 .await
352 .map_err(|e| {
353 TransferError::new(format!("read upload file metadata failed: {}", e))
354 .with_path(file_path)
355 })?
356 .len();
357 let uploaded = Arc::new(AtomicU64::new(0));
358 let started = Instant::now();
359 let last_emit = Arc::new(Mutex::new(Instant::now() - Duration::from_millis(120)));
360 let progress_cb: Arc<dyn Fn(TransferProgress) + Send + Sync> = Arc::new(progress_cb);
361
362 let stream_file_name = file_name.clone();
363 let stream_uploaded = uploaded.clone();
364 let stream_last_emit = last_emit.clone();
365 let stream_progress_cb = progress_cb.clone();
366 let stream_cancel = cancel_token.clone();
367 let stream = ReaderStream::new(file).map(move |result| {
368 if stream_cancel
369 .as_ref()
370 .is_some_and(|token| token.load(Ordering::SeqCst))
371 {
372 return Err(std::io::Error::new(
373 std::io::ErrorKind::Interrupted,
374 "Upload cancelled",
375 ));
376 }
377
378 let chunk = result?;
379 let chunk_len = chunk.len() as u64;
380 let total_uploaded = stream_uploaded.fetch_add(chunk_len, Ordering::SeqCst) + chunk_len;
381 let mut should_emit = false;
382 if let Ok(mut last) = stream_last_emit.lock() {
383 if last.elapsed().as_millis() >= 120 {
384 *last = Instant::now();
385 should_emit = true;
386 }
387 }
388 if should_emit {
389 emit_progress(
390 stream_progress_cb.as_ref(),
391 ProgressEmit {
392 file_name: stream_file_name.clone(),
393 bytes: total_uploaded,
394 total_bytes: Some(file_size),
395 started,
396 session_bytes: total_uploaded,
397 done: false,
398 failed: false,
399 },
400 );
401 }
402 Ok(chunk)
403 });
404
405 let part =
406 reqwest::multipart::Part::stream_with_length(reqwest::Body::wrap_stream(stream), file_size)
407 .file_name(file_name.clone())
408 .mime_str("audio/*")
409 .map_err(|e| {
410 TransferError::new(format!("build upload multipart part failed: {}", e))
411 .with_url(url)
412 .with_path(file_path)
413 })?;
414
415 let mut form = reqwest::multipart::Form::new().part("audiofile", part);
416 for (key, val) in extra_fields {
417 form = form.text(key, val);
418 }
419
420 let response = client.post(url).multipart(form).send().await.map_err(|e| {
421 if cancel_token
422 .as_ref()
423 .is_some_and(|token| token.load(Ordering::SeqCst))
424 {
425 TransferError::cancelled("Upload cancelled")
426 .with_url(url)
427 .with_path(file_path)
428 } else {
429 TransferError::new(format!("Upload failed: {}", e))
430 .with_url(url)
431 .with_path(file_path)
432 }
433 })?;
434
435 let status = response.status();
436 let status_code = status.as_u16();
437 let body_text = response.text().await.map_err(|e| {
438 TransferError::new(format!("read upload response body failed: {}", e))
439 .with_url(url)
440 .with_path(file_path)
441 .with_http_status(status_code)
442 })?;
443 if !status.is_success() {
444 return Err(
445 TransferError::new(upload_error_message(status_code, &body_text))
446 .with_url(url)
447 .with_path(file_path)
448 .with_http_status(status_code),
449 );
450 }
451
452 let hash = extract_upload_hash(&body_text)
453 .filter(|hash| !hash.is_empty())
454 .ok_or_else(|| {
455 TransferError::new("Failed to get task hash")
456 .with_url(url)
457 .with_path(file_path)
458 .with_http_status(status_code)
459 })?;
460
461 let total_uploaded = uploaded.load(Ordering::SeqCst).max(file_size);
462 emit_progress(
463 progress_cb.as_ref(),
464 ProgressEmit {
465 file_name,
466 bytes: total_uploaded,
467 total_bytes: Some(file_size),
468 started,
469 session_bytes: total_uploaded,
470 done: true,
471 failed: false,
472 },
473 );
474
475 Ok(hash)
476}
477
478pub fn upload_file(
479 proxy_host: &str,
480 proxy_port: u16,
481 proxy_mode: &str,
482 url: &str,
483 file_path: &Path,
484 extra_fields: Vec<(String, String)>,
485 progress_cb: impl Fn(TransferProgress) + Send + Sync + 'static,
486) -> anyhow::Result<String> {
487 let runtime = match tokio::runtime::Runtime::new() {
488 Ok(r) => r,
489 Err(e) => anyhow::bail!("Failed to start runtime: {}", e),
490 };
491
492 let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(300));
493 if proxy_mode == "manual" {
494 let p = format!("http://{}:{}", proxy_host, proxy_port);
495 if let Ok(proxy) = reqwest::Proxy::all(&p) {
496 builder = builder.proxy(proxy);
497 }
498 } else if proxy_mode == "none" {
499 builder = builder.no_proxy();
500 }
501 let client = builder.build()?;
502
503 runtime
504 .block_on(upload_file_async(
505 &client,
506 url,
507 file_path,
508 extra_fields,
509 None,
510 progress_cb,
511 ))
512 .map_err(anyhow::Error::new)
513}
514
515pub async fn download_file_async(
523 client: &reqwest::Client,
524 file_url: &str,
525 dest_path: &Path,
526 remote_file_name: &str,
527 cancel_token: Option<Arc<AtomicBool>>,
528 progress_cb: impl Fn(TransferProgress) + Send + Sync + 'static,
529) -> Result<(), TransferError> {
530 let part = part_path(dest_path);
531 let meta = meta_path(&part);
532
533 if let Some(parent) = part.parent() {
534 tokio::fs::create_dir_all(parent).await.map_err(|e| {
535 TransferError::new(format!("create partial download directory failed: {}", e))
536 .with_url(file_url)
537 .with_path(parent)
538 })?;
539 }
540 if let Some(parent) = meta.parent() {
541 tokio::fs::create_dir_all(parent).await.map_err(|e| {
542 TransferError::new(format!("create partial metadata directory failed: {}", e))
543 .with_url(file_url)
544 .with_path(parent)
545 })?;
546 }
547
548 let mut resume_from = get_resume_info_async(dest_path, file_url).await;
549 let pm = PartialDownloadMeta {
550 file_url: file_url.to_string(),
551 remote_file_name: remote_file_name.to_string(),
552 updated_at: now_timestamp(),
553 };
554 save_partial_download_meta_async(&meta, &pm).await?;
555
556 if cancel_token
557 .as_ref()
558 .is_some_and(|token| token.load(Ordering::SeqCst))
559 {
560 return Err(TransferError::cancelled("Download cancelled")
561 .with_url(file_url)
562 .with_path(dest_path));
563 }
564
565 let mut request = client.get(file_url);
566 if resume_from > 0 {
567 request = request.header(reqwest::header::RANGE, format!("bytes={}-", resume_from));
568 }
569
570 let response = request.send().await.map_err(|e| {
571 TransferError::new(format!("Download request failed: {}", e))
572 .with_url(file_url)
573 .with_path(dest_path)
574 })?;
575 let status = response.status();
576 let (append_mode, total_bytes) =
577 if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
578 let total = parse_total_bytes_from_headers(response.headers())
579 .or_else(|| response.content_length().map(|len| resume_from + len));
580 (true, total)
581 } else if status.is_success() {
582 if resume_from > 0 {
583 resume_from = 0;
584 let _ = tokio::fs::remove_file(&part).await;
585 }
586 (false, response.content_length())
587 } else {
588 return Err(
589 TransferError::new(format!("HTTP {} while downloading file", status))
590 .with_url(file_url)
591 .with_path(dest_path)
592 .with_http_status(status.as_u16()),
593 );
594 };
595
596 let mut options = tokio::fs::OpenOptions::new();
597 options.create(true).write(true);
598 if append_mode {
599 options.append(true);
600 } else {
601 options.truncate(true);
602 }
603 let mut file = options.open(&part).await.map_err(|e| {
604 TransferError::new(format!("open partial download file failed: {}", e))
605 .with_url(file_url)
606 .with_path(&part)
607 })?;
608
609 let progress_cb: Arc<dyn Fn(TransferProgress) + Send + Sync> = Arc::new(progress_cb);
610 let file_name = dest_path
611 .file_name()
612 .and_then(|s| s.to_str())
613 .unwrap_or("output")
614 .to_string();
615 let mut stream = response.bytes_stream();
616 let mut downloaded: u64 = resume_from;
617 let mut session_downloaded: u64 = 0;
618 let started = Instant::now();
619 let mut last_emit = Instant::now() - Duration::from_millis(150);
620
621 while let Some(chunk) = stream.next().await {
622 let chunk = chunk.map_err(|e| {
623 TransferError::new(format!("read download response chunk failed: {}", e))
624 .with_url(file_url)
625 .with_path(dest_path)
626 })?;
627 file.write_all(&chunk).await.map_err(|e| {
628 TransferError::new(format!("write partial download file failed: {}", e))
629 .with_url(file_url)
630 .with_path(&part)
631 })?;
632 let chunk_len = chunk.len() as u64;
633 downloaded += chunk_len;
634 session_downloaded += chunk_len;
635
636 if last_emit.elapsed().as_millis() >= 150 {
637 emit_progress(
638 progress_cb.as_ref(),
639 ProgressEmit {
640 file_name: file_name.clone(),
641 bytes: downloaded,
642 total_bytes,
643 started,
644 session_bytes: session_downloaded,
645 done: false,
646 failed: false,
647 },
648 );
649 last_emit = Instant::now();
650 }
651
652 if cancel_token
653 .as_ref()
654 .is_some_and(|token| token.load(Ordering::SeqCst))
655 {
656 file.flush().await.map_err(|e| {
657 TransferError::new(format!("flush partial download file failed: {}", e))
658 .with_url(file_url)
659 .with_path(&part)
660 })?;
661 return Err(TransferError::cancelled("Download cancelled")
662 .with_url(file_url)
663 .with_path(dest_path));
664 }
665 }
666
667 file.flush().await.map_err(|e| {
668 TransferError::new(format!("flush partial download file failed: {}", e))
669 .with_url(file_url)
670 .with_path(&part)
671 })?;
672
673 if dest_path.exists() {
674 tokio::fs::remove_file(dest_path).await.map_err(|e| {
675 TransferError::new(format!("remove existing output file failed: {}", e))
676 .with_url(file_url)
677 .with_path(dest_path)
678 })?;
679 }
680 tokio::fs::rename(&part, dest_path).await.map_err(|e| {
681 TransferError::new(format!("finalize downloaded file failed: {}", e))
682 .with_url(file_url)
683 .with_path(dest_path)
684 })?;
685 let _ = tokio::fs::remove_file(&meta).await;
686
687 emit_progress(
688 progress_cb.as_ref(),
689 ProgressEmit {
690 file_name,
691 bytes: downloaded,
692 total_bytes,
693 started,
694 session_bytes: session_downloaded,
695 done: true,
696 failed: false,
697 },
698 );
699
700 Ok(())
701}
702
703pub fn download_file(
709 client: &reqwest::blocking::Client,
710 file_url: &str,
711 dest_path: &Path,
712 resume_from: u64,
713 progress_cb: impl Fn(TransferProgress) + Send,
714) -> anyhow::Result<()> {
715 let part = part_path(dest_path);
716 let meta = meta_path(&part);
717
718 if let Some(parent) = part.parent() {
720 std::fs::create_dir_all(parent)?;
721 }
722 let pm = PartialDownloadMeta {
723 file_url: file_url.to_string(),
724 remote_file_name: dest_path
725 .file_name()
726 .and_then(|s| s.to_str())
727 .unwrap_or("output")
728 .to_string(),
729 updated_at: now_timestamp(),
730 };
731 if let Ok(content) = serde_json::to_string_pretty(&pm) {
732 let _ = std::fs::write(&meta, &content);
733 }
734
735 let mut request = client.get(file_url);
737 if resume_from > 0 {
738 request = request.header(reqwest::header::RANGE, format!("bytes={}-", resume_from));
739 }
740
741 let response = request.send()?;
742 let status = response.status();
743
744 let (append_mode, total_bytes) =
745 if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
746 let total = parse_total_bytes_from_content_range(&response)
747 .or_else(|| response.content_length().map(|len| resume_from + len));
748 (true, total)
749 } else if status.is_success() {
750 if resume_from > 0 {
751 }
753 let total = response.content_length();
754 let _ = std::fs::remove_file(&part);
755 (false, total)
756 } else {
757 anyhow::bail!("HTTP {} while downloading file", status);
758 };
759
760 let mut file = std::fs::OpenOptions::new()
762 .create(true)
763 .write(true)
764 .append(append_mode)
765 .open(&part)?;
766
767 let mut bytes_stream = response.take(usize::MAX as u64);
769 let mut downloaded: u64 = resume_from;
770 let mut last_emit = Instant::now();
771 let start = Instant::now();
772 let mut session_downloaded: u64 = 0;
773 let mut buf = [0u8; 65536]; let file_name = dest_path
776 .file_name()
777 .and_then(|s| s.to_str())
778 .unwrap_or("output")
779 .to_string();
780
781 loop {
782 let n = bytes_stream.read(&mut buf)?;
783 if n == 0 {
784 break;
785 }
786 file.write_all(&buf[..n])?;
787 downloaded += n as u64;
788 session_downloaded += n as u64;
789
790 if last_emit.elapsed().as_millis() >= 150 {
791 let elapsed = start.elapsed().as_secs_f64().max(0.001);
792 let speed = session_downloaded as f64 / elapsed;
793 let pct = total_bytes
794 .map(|t| (downloaded as f32 / t.max(1) as f32) * 100.0)
795 .unwrap_or(0.0);
796
797 progress_cb(TransferProgress {
798 file_name: file_name.clone(),
799 bytes: downloaded,
800 total_bytes,
801 speed_bps: speed,
802 percent: pct,
803 done: false,
804 failed: false,
805 });
806 last_emit = Instant::now();
807 }
808 }
809
810 file.flush()?;
811
812 if dest_path.exists() {
814 std::fs::remove_file(dest_path)?;
815 }
816 std::fs::rename(&part, dest_path)?;
817 let _ = std::fs::remove_file(&meta);
818
819 progress_cb(TransferProgress {
821 file_name,
822 bytes: downloaded,
823 total_bytes,
824 speed_bps: 0.0,
825 percent: 100.0,
826 done: true,
827 failed: false,
828 });
829
830 Ok(())
831}
832
833fn parse_total_bytes_from_headers(headers: &reqwest::header::HeaderMap) -> Option<u64> {
834 let value = headers.get(reqwest::header::CONTENT_RANGE)?;
835 let raw = value.to_str().ok()?;
836 let slash = raw.rfind('/')?;
837 raw[(slash + 1)..].trim().parse::<u64>().ok()
838}
839
840fn parse_total_bytes_from_content_range(response: &reqwest::blocking::Response) -> Option<u64> {
842 parse_total_bytes_from_headers(response.headers())
843}
844
845pub fn get_resume_info(final_path: &Path, file_url: &str) -> u64 {
848 let part = part_path(final_path);
849 let meta = meta_path(&part);
850
851 if !part.exists() {
852 return 0;
853 }
854 let part_size = match std::fs::metadata(&part) {
855 Ok(m) => m.len(),
856 Err(_) => return 0,
857 };
858 if part_size == 0 {
859 return 0;
860 }
861
862 let can_resume = match std::fs::read_to_string(&meta) {
864 Ok(content) => match serde_json::from_str::<PartialDownloadMeta>(&content) {
865 Ok(pm) => pm.file_url == file_url,
866 Err(_) => true, },
868 Err(_) => true,
869 };
870
871 if can_resume {
872 part_size
873 } else {
874 let _ = std::fs::remove_file(&part);
875 let _ = std::fs::remove_file(&meta);
876 0
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use std::net::{TcpListener, TcpStream};
884 use std::sync::mpsc::{self, Receiver};
885 use std::thread;
886 use std::time::Duration;
887
888 fn test_client() -> reqwest::Client {
889 reqwest::Client::builder().no_proxy().build().unwrap()
890 }
891
892 fn read_http_request(stream: &mut TcpStream) -> String {
893 let mut raw = Vec::new();
894 let mut byte = [0_u8; 1];
895 while !raw.ends_with(b"\r\n\r\n") {
896 stream.read_exact(&mut byte).unwrap();
897 raw.push(byte[0]);
898 }
899 let header = String::from_utf8_lossy(&raw).to_string();
900 let content_length = header
901 .lines()
902 .find_map(|line| {
903 let (name, value) = line.split_once(':')?;
904 if name.eq_ignore_ascii_case("content-length") {
905 value.trim().parse::<usize>().ok()
906 } else {
907 None
908 }
909 })
910 .unwrap_or(0);
911 let mut body = vec![0_u8; content_length];
912 if content_length > 0 {
913 stream.read_exact(&mut body).unwrap();
914 }
915 format!("{}{}", header, String::from_utf8_lossy(&body))
916 }
917
918 fn write_response(
919 stream: &mut TcpStream,
920 status: &str,
921 headers: &[(&str, String)],
922 body: &[u8],
923 ) {
924 let mut response = format!("HTTP/1.1 {}\r\nContent-Length: {}\r\n", status, body.len());
925 for (name, value) in headers {
926 response.push_str(name);
927 response.push_str(": ");
928 response.push_str(value);
929 response.push_str("\r\n");
930 }
931 response.push_str("\r\n");
932 stream.write_all(response.as_bytes()).unwrap();
933 stream.write_all(body).unwrap();
934 stream.flush().unwrap();
935 }
936
937 fn spawn_server(
938 handler: impl FnOnce(String, TcpStream) + Send + 'static,
939 ) -> (String, Receiver<String>, thread::JoinHandle<()>) {
940 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
941 let addr = listener.local_addr().unwrap();
942 let (tx, rx) = mpsc::channel();
943 let handle = thread::spawn(move || {
944 let (mut stream, _) = listener.accept().unwrap();
945 let request = read_http_request(&mut stream);
946 tx.send(request.clone()).unwrap();
947 handler(request, stream);
948 });
949 (format!("http://{}", addr), rx, handle)
950 }
951
952 #[test]
953 fn async_upload_extracts_hash_from_mock_http_response() {
954 let (base_url, rx, handle) = spawn_server(|request, mut stream| {
955 assert!(request.starts_with("POST "));
956 assert!(request.contains("audiofile"));
957 assert!(request.contains("api_token"));
958 write_response(
959 &mut stream,
960 "200 OK",
961 &[("Content-Type", "application/json".to_string())],
962 br#"{"data":{"hash":"hash-from-upload"}}"#,
963 );
964 });
965 let temp = tempfile::tempdir().unwrap();
966 let file_path = temp.path().join("song.wav");
967 std::fs::write(&file_path, b"audio-bytes").unwrap();
968 let progress = Arc::new(Mutex::new(Vec::<TransferProgress>::new()));
969 let progress_for_cb = progress.clone();
970
971 let hash = tokio::runtime::Runtime::new()
972 .unwrap()
973 .block_on(upload_file_async(
974 &test_client(),
975 &format!("{}/upload", base_url),
976 &file_path,
977 vec![("api_token".to_string(), "token".to_string())],
978 None,
979 move |p| progress_for_cb.lock().unwrap().push(p),
980 ));
981
982 assert_eq!(hash.unwrap(), "hash-from-upload");
983 assert!(rx.recv().unwrap().contains("audio-bytes"));
984 handle.join().unwrap();
985 assert!(progress.lock().unwrap().iter().any(|p| p.done && !p.failed));
986 }
987
988 #[test]
989 fn async_download_resumes_with_range_header() {
990 let (base_url, rx, handle) = spawn_server(|_request, mut stream| {
991 write_response(
992 &mut stream,
993 "206 Partial Content",
994 &[("Content-Range", "bytes 6-10/11".to_string())],
995 b"world",
996 );
997 });
998 let file_url = format!("{}/file.wav", base_url);
999 let temp = tempfile::tempdir().unwrap();
1000 let dest_path = temp.path().join("song_vocals.wav");
1001 std::fs::write(part_path(&dest_path), b"hello ").unwrap();
1002 let meta = PartialDownloadMeta {
1003 file_url: file_url.clone(),
1004 remote_file_name: "remote.wav".to_string(),
1005 updated_at: now_timestamp(),
1006 };
1007 std::fs::write(
1008 meta_path(&part_path(&dest_path)),
1009 serde_json::to_string(&meta).unwrap(),
1010 )
1011 .unwrap();
1012
1013 tokio::runtime::Runtime::new()
1014 .unwrap()
1015 .block_on(download_file_async(
1016 &test_client(),
1017 &file_url,
1018 &dest_path,
1019 "remote.wav",
1020 None,
1021 |_| {},
1022 ))
1023 .unwrap();
1024
1025 let request = rx.recv().unwrap();
1026 handle.join().unwrap();
1027 assert!(request.to_ascii_lowercase().contains("range: bytes=6-"));
1028 assert_eq!(std::fs::read(&dest_path).unwrap(), b"hello world");
1029 assert!(!part_path(&dest_path).exists());
1030 assert!(!meta_path(&part_path(&dest_path)).exists());
1031 }
1032
1033 #[test]
1034 fn async_download_restarts_when_server_rejects_range() {
1035 let (base_url, rx, handle) = spawn_server(|_request, mut stream| {
1036 write_response(&mut stream, "200 OK", &[], b"complete-file");
1037 });
1038 let file_url = format!("{}/file.wav", base_url);
1039 let temp = tempfile::tempdir().unwrap();
1040 let dest_path = temp.path().join("song_vocals.wav");
1041 std::fs::write(part_path(&dest_path), b"partial").unwrap();
1042 let meta = PartialDownloadMeta {
1043 file_url: file_url.clone(),
1044 remote_file_name: "remote.wav".to_string(),
1045 updated_at: now_timestamp(),
1046 };
1047 std::fs::write(
1048 meta_path(&part_path(&dest_path)),
1049 serde_json::to_string(&meta).unwrap(),
1050 )
1051 .unwrap();
1052
1053 tokio::runtime::Runtime::new()
1054 .unwrap()
1055 .block_on(download_file_async(
1056 &test_client(),
1057 &file_url,
1058 &dest_path,
1059 "remote.wav",
1060 None,
1061 |_| {},
1062 ))
1063 .unwrap();
1064
1065 let request = rx.recv().unwrap();
1066 handle.join().unwrap();
1067 assert!(request.to_ascii_lowercase().contains("range: bytes=7-"));
1068 assert_eq!(std::fs::read(&dest_path).unwrap(), b"complete-file");
1069 }
1070
1071 #[test]
1072 fn async_download_cancel_keeps_resumable_partial_files() {
1073 let (base_url, _rx, handle) = spawn_server(|_request, mut stream| {
1074 let headers = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\n";
1075 stream.write_all(headers.as_bytes()).unwrap();
1076 stream.write_all(b"hello ").unwrap();
1077 stream.flush().unwrap();
1078 thread::sleep(Duration::from_millis(250));
1079 let _ = stream.write_all(b"world!");
1080 let _ = stream.flush();
1081 });
1082 let file_url = format!("{}/file.wav", base_url);
1083 let temp = tempfile::tempdir().unwrap();
1084 let dest_path = temp.path().join("song_vocals.wav");
1085 let cancel = Arc::new(AtomicBool::new(false));
1086 let cancel_for_cb = cancel.clone();
1087
1088 let result = tokio::runtime::Runtime::new()
1089 .unwrap()
1090 .block_on(download_file_async(
1091 &test_client(),
1092 &file_url,
1093 &dest_path,
1094 "remote.wav",
1095 Some(cancel),
1096 move |p| {
1097 if !p.done {
1098 cancel_for_cb.store(true, Ordering::SeqCst);
1099 }
1100 },
1101 ));
1102
1103 assert!(result
1104 .unwrap_err()
1105 .to_string()
1106 .to_lowercase()
1107 .contains("download cancelled"));
1108 handle.join().unwrap();
1109 assert!(part_path(&dest_path).exists());
1110 assert!(meta_path(&part_path(&dest_path)).exists());
1111 assert_eq!(std::fs::read(part_path(&dest_path)).unwrap(), b"hello ");
1112 assert!(!dest_path.exists());
1113 }
1114}