Skip to main content

mvsep_api_tester/
file_transfer.rs

1//! 文件传输模块。
2//!
3//! 提供流式上传(async + 进度回调)、流式下载(断点续传 + 进度追踪)、
4//! 文件重命名等功能。
5//!
6//! ## 上传
7//!
8//! [`upload_file`] 使用 tokio 异步运行时读取文件并流式发送 multipart 请求,
9//! 通过回调实时报告上传进度。
10//!
11//! ## 下载
12//!
13//! [`download_file`] 流式读取响应体并写入磁盘,支持:
14//! - **断点续传**:通过 `Range` 头 + `.part` 文件实现
15//! - **进度回调**:每 150ms 报告速度、百分比、已传输字节
16//! - **完成重命名**:`.part` → 最终文件
17//!
18//! ## 重命名
19//!
20//! [`build_local_name`] 将远端产物名映射为本地文件名:
21//! ```text
22//! 远端: 20260626..._melroformer_mt_10_vocals.flac
23//! 本地: {原文件名主干}_vocals.flac
24//! ```
25
26use 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/// Progress information for upload/download operations.
37#[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/// Structured transfer error used by async callers that need to preserve
49/// HTTP/file context before converting to UI-facing strings.
50#[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/// Metadata stored alongside `.part` files for download resume.
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119struct PartialDownloadMeta {
120    file_url: String,
121    remote_file_name: String,
122    updated_at: String,
123}
124
125// ── Path helpers ──
126
127/// Path to the `.part` file for partial downloads.
128pub 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
138/// Path to the `.meta.json` file for partial download metadata.
139pub 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
204// ── File naming ──
205
206/// Extract the stem (filename without extension) from a path.
207fn 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
215/// Build a local filename from the original uploaded filename and the remote output filename.
216///
217/// Remote filenames follow the pattern: `{hash}_{...}_{suffix}.{ext}`
218/// We extract everything after the last `_` (the suffix + extension),
219/// and prefix it with the original file's stem.
220///
221/// Example:
222///   original: "螢塚-Calvaria.mp3"
223///   remote:   "20260626..._melroformer_mt_10_vocals.flac"
224///   result:   "螢塚-Calvaria_vocals.flac"
225pub fn build_local_name(original_name: &str, remote_name: &str) -> String {
226    let original_stem = sanitize_name(&file_stem(original_name));
227
228    // Find the last `_` in the remote name to isolate the suffix + ext
229    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        // No underscore found — just use remote name as-is
234        remote_name.to_string()
235    }
236}
237
238/// Sanitize a filename component by replacing problematic characters.
239fn 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
257// ── Upload ──
258
259fn 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
329/// Upload a file with async streaming and progress reporting.
330///
331/// The caller supplies the async `reqwest::Client`, so GUI adapters can reuse
332/// their proxy-aware client without creating nested runtimes.
333pub 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
515// ── Download ──
516
517/// Download a file with async streaming, automatic resume support, cancellation,
518/// and progress reporting.
519///
520/// Cancellation returns an error containing `Download cancelled` and leaves the
521/// `.part` file plus metadata in place so a later call can resume.
522pub 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
703/// Download a file with streaming, resume support, and progress reporting.
704///
705/// If `resume_from > 0`, sends a `Range` header and appends to the existing `.part` file.
706/// Progress is reported every ~150ms during the download.
707/// On completion, renames `.part` to the final path.
708pub 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    // Save resume metadata
719    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    // Build request with optional Range header
736    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                // Server doesn't support resume, restart from scratch
752            }
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    // Open file for writing
761    let mut file = std::fs::OpenOptions::new()
762        .create(true)
763        .write(true)
764        .append(append_mode)
765        .open(&part)?;
766
767    // Stream the response body to disk with progress
768    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]; // 64KB buffer
774
775    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    // Rename .part → final file
813    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    // Final progress report
820    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
840/// Parse total bytes from the `Content-Range` response header.
841fn parse_total_bytes_from_content_range(response: &reqwest::blocking::Response) -> Option<u64> {
842    parse_total_bytes_from_headers(response.headers())
843}
844
845/// Check if a `.part` file exists and can be resumed.
846/// Returns the number of bytes already downloaded.
847pub 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    // Check meta file matches the URL
863    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, // If we can't parse meta, try resuming anyway
867        },
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}