soundcloud-rs 0.11.0

A simple Rust client for the SoundCloud API
Documentation
use ffmpeg_sidecar::command::FfmpegCommand;
use ffmpeg_sidecar::download;
use std::error::Error;
use std::path::{Path, PathBuf};

use crate::client::client::Client;
use crate::models::client::SoundcloudIdentifier;
use crate::models::query::{Paging, TracksQuery};
use crate::models::response::{Track, Tracks};
use crate::response::{Stream, StreamType, Transcoding, Waveform};

impl Client {
    pub async fn search_tracks(
        &self,
        query: Option<&TracksQuery>,
    ) -> Result<Tracks, Box<dyn Error>> {
        let tracks: Tracks = self.get("search/tracks", query).await?;
        Ok(tracks)
    }

    pub async fn get_track(
        &self,
        identifier: &SoundcloudIdentifier,
    ) -> Result<Track, Box<dyn Error>> {
        let url = format!("tracks/{identifier}");
        let resp: Track = self.get(&url, None::<&()>).await?;
        Ok(resp)
    }

    pub async fn get_track_related(
        &self,
        identifier: &SoundcloudIdentifier,
        pagination: Option<&Paging>,
    ) -> Result<Tracks, Box<dyn Error>> {
        let url = format!("tracks/{identifier}/related");
        let resp: Tracks = self.get(&url, pagination).await?;
        Ok(resp)
    }

    pub async fn download_track(
        &self,
        identifier: &SoundcloudIdentifier,
        stream_type: Option<&StreamType>,
        destination: Option<&str>,
        filename: Option<&str>,
    ) -> Result<(), Box<dyn Error>> {
        let track = self.get_track(identifier).await?;

        let stream = match stream_type {
            Some(stream_type) => stream_type,
            None => &StreamType::Progressive,
        };

        if track.title.is_none() {
            return Err("Track title is missing".into());
        }

        let title = match filename {
            Some(filename) => filename,
            None => track.title.as_ref().expect("Missing track title"),
        };

        let output_path = match destination {
            Some(destination) => PathBuf::from(destination).join(format!("{title}.mp3")),
            None => PathBuf::from(format!("{title}.mp3")),
        };
        if let Some(parent) = output_path.parent() {
            if !parent.exists() {
                std::fs::create_dir_all(parent)?;
            }
        }

        let transcoding = self.get_transcoding_by_stream_type(&track, stream).await?;
        let stream_url = self.get_stream_url(identifier, Some(stream)).await?;

        match transcoding
            .format
            .as_ref()
            .expect("Missing transcoding format")
            .protocol
            .as_ref()
        {
            Some(StreamType::Progressive) => {
                self.download_progressive(&stream_url, &output_path).await?
            }
            Some(StreamType::Hls) => self.download_hls(&stream_url, &output_path).await?,
            _ => return Err("Invalid Stream Type".into()),
        }

        Ok(())
    }

    pub async fn get_track_waveform(
        &self,
        identifier: &SoundcloudIdentifier,
    ) -> Result<Waveform, Box<dyn Error>> {
        let track = self.get_track(identifier).await?;
        let waveform_url = track.waveform_url.as_ref().expect("Missing waveform URL");
        let response = reqwest::get(waveform_url).await?;
        let waveform: Waveform = response.json::<Waveform>().await?;
        Ok(waveform)
    }

    pub async fn get_stream_url(
        &self,
        identifier: &SoundcloudIdentifier,
        stream_type: Option<&StreamType>,
    ) -> Result<String, Box<dyn Error>> {
        let track = self.get_track(identifier).await?;
        let stream = match stream_type {
            Some(stream_type) => stream_type,
            None => &StreamType::Progressive,
        };
        let transcoding = self.get_transcoding_by_stream_type(&track, stream).await?;
        let path = transcoding.url.as_ref().ok_or("Missing transcoding URL")?;
        let stream: Stream = Self::get_json(path, None, None::<&()>, &self.client_id).await?;
        stream.url.ok_or("Missing resolved stream URL".into())
    }

    async fn get_transcoding_by_stream_type(
        &self,
        track: &Track,
        stream_type: &StreamType,
    ) -> Result<Transcoding, Box<dyn Error>> {
        let transcodings = track
            .media
            .as_ref()
            .expect("Missing media")
            .transcodings
            .as_ref()
            .expect("Missing transcodings");
        if transcodings.is_empty() {
            return Err("No available download options".into());
        }

        let transcoding: Option<Transcoding> = {
            for t in transcodings {
                let protocol = match t.format.as_ref().and_then(|f| f.protocol.as_ref()) {
                    Some(p) => p,
                    None => continue,
                };
                if *protocol != *stream_type {
                    continue;
                }

                let path = match t.url.as_ref() {
                    Some(u) => u,
                    None => continue,
                };

                let stream: Stream =
                    Self::get_json(path, None, None::<&()>, &self.client_id).await?;
                if stream.url.is_some() {
                    return Ok(t.clone());
                }
            }
            None
        };
        Ok(transcoding.expect("No available download options"))
    }

    async fn download_progressive(
        &self,
        stream_url: &str,
        output_path: &Path,
    ) -> Result<(), Box<dyn Error>> {
        let response = reqwest::get(stream_url).await?;
        let bytes = response.bytes().await?;
        tokio::fs::write(output_path, &bytes).await?;
        Ok(())
    }

    async fn download_hls(
        &self,
        stream_url: &str,
        output_path: &Path,
    ) -> Result<(), Box<dyn Error>> {
        download::auto_download()?;
        let status = FfmpegCommand::new()
            .input(stream_url)
            .output(
                output_path
                    .to_str()
                    .expect("Failed to convert output path to string"),
            )
            .args(["-c", "copy"])
            .spawn()?
            .wait()?;

        if !status.success() {
            return Err("Download HLS Failed".into());
        }
        Ok(())
    }
}