rs-suno 0.2.0

A download-only command-line tool for mirroring your Suno.ai library.
//! Disk and CDN helpers for the `fetch` command: public downloads, cover-art
//! selection, and atomic file writes into the `downloads/` directory.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, bail};
use suno_core::{Clip, Http, HttpRequest};

static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Download a public resource (CDN audio, rendered WAV, or cover art).
///
/// These URLs are unauthenticated, so the request carries no token; it reuses
/// the engine's [`Http`] port for a single GET.
pub async fn get_bytes(http: &impl Http, url: &str) -> Result<Vec<u8>> {
    let response = http
        .send(HttpRequest::get(url))
        .await
        .map_err(|err| anyhow::anyhow!("request failed: {err}"))?;
    if !(200..=299).contains(&response.status) {
        bail!("download failed for {url}: status {}", response.status);
    }
    Ok(response.body)
}

/// Download the clip's cover art, returning `None` if unavailable (non-fatal).
pub async fn cover(http: &impl Http, clip: &Clip) -> Option<Vec<u8>> {
    let url = clip.selected_image_url()?;
    get_bytes(http, url).await.ok()
}

/// Write `bytes` to `path` atomically via a temporary file and rename.
///
/// The temp name is process-unique so two concurrent writers never race on it,
/// and a drop guard removes it if writing or the final rename fails.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
    let tmp = temp_sibling(path);
    let _scratch = Scratch(tmp.clone());
    std::fs::write(&tmp, bytes).with_context(|| format!("could not write {}", tmp.display()))?;
    replace(&tmp, path).with_context(|| format!("could not finalise {}", path.display()))?;
    Ok(())
}

/// Rename `from` onto `to`, replacing any existing destination without ever
/// leaving `to` missing.
///
/// `std::fs::rename` overwrites atomically on Unix but fails on Windows when the
/// destination exists. The fallback first stashes the current destination aside,
/// swaps in the new file, and only drops the stash once the swap succeeds; a
/// failed swap restores the stash, so a valid file always sits at `to`.
pub(crate) fn replace(from: &Path, to: &Path) -> std::io::Result<()> {
    match std::fs::rename(from, to) {
        Ok(()) => Ok(()),
        Err(_) if to.exists() => {
            let backup = to.with_file_name(format!(
                ".{}.{}.bak",
                to.file_name()
                    .map(|n| n.to_string_lossy())
                    .unwrap_or_default(),
                unique_stamp()
            ));
            std::fs::rename(to, &backup)?;
            match std::fs::rename(from, to) {
                Ok(()) => {
                    let _ = std::fs::remove_file(&backup);
                    Ok(())
                }
                Err(err) => {
                    let _ = std::fs::rename(&backup, to);
                    Err(err)
                }
            }
        }
        Err(err) => Err(err),
    }
}

/// A hidden, same-directory temporary path so the rename stays on one device.
fn temp_sibling(path: &Path) -> PathBuf {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "download".to_owned());
    path.with_file_name(format!(".{name}.{}.part", unique_stamp()))
}

/// A process- and call-unique stamp for temporary file names.
fn unique_stamp() -> String {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("{}-{nanos}-{seq}", std::process::id())
}

/// Removes its temporary path when dropped, even on the error path.
struct Scratch(PathBuf);

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn write_atomic_replaces_and_leaves_no_temp() {
        let dir = Path::new("target").join(format!("write-atomic-{}", unique_stamp()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("clip.bin");

        write_atomic(&path, b"first").unwrap();
        write_atomic(&path, b"second").unwrap();

        assert_eq!(std::fs::read(&path).unwrap(), b"second");

        let names: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(Result::ok)
            .map(|entry| entry.file_name().to_string_lossy().into_owned())
            .collect();
        assert_eq!(names, vec!["clip.bin".to_owned()]);

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn replace_overwrites_existing_and_leaves_no_backup() {
        let dir = Path::new("target").join(format!("replace-{}", unique_stamp()));
        std::fs::create_dir_all(&dir).unwrap();
        let to = dir.join("dest.bin");
        let from = dir.join("src.bin");
        std::fs::write(&to, b"old").unwrap();
        std::fs::write(&from, b"new").unwrap();

        replace(&from, &to).unwrap();

        assert_eq!(std::fs::read(&to).unwrap(), b"new");
        let names: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(Result::ok)
            .map(|entry| entry.file_name().to_string_lossy().into_owned())
            .collect();
        assert_eq!(names, vec!["dest.bin".to_owned()]);

        let _ = std::fs::remove_dir_all(&dir);
    }
}