qobuz-api 4.0.0

A Rust client library for the Qobuz music streaming API
//! Download delegates for the central API service.

use std::{
    path::{Path, PathBuf},
    sync::{Arc, atomic::AtomicBool},
};

use {tokio::runtime::Runtime, tracing::info};

use crate::{
    api::{
        content::{
            bundle::download_album,
            discography::download_artist,
            mixtape::download_playlist,
            stream::{FileUrl, get_track_file_url},
            tracks::download_track,
        },
        service_state::QobuzApiService,
    },
    errors::QobuzApiError,
    metadata::config::MetadataConfig,
};

impl QobuzApiService {
    delegate_with_retry!(
        /// Gets the download URL for a track at the specified quality.
        ///
        /// # Arguments
        ///
        /// * `track_id` - Track identifier
        /// * `format_id` - Quality format ID (5=MP3, 6=FLAC 16-bit, 7=FLAC 24-bit/96kHz, 27=FLAC
        ///   24-bit/192kHz)
        ///
        /// # Returns
        ///
        /// The download URL and metadata for the track.
        pub fn get_track_file_url(track_id: i32, format_id: i32) -> FileUrl = get_track_file_url
    );

    delegate_with_retry_cancellable!(
        /// Downloads a single track with cancellation support.
        ///
        /// # Arguments
        ///
        /// * `track_id` - Track identifier
        /// * `format_id` - Quality format ID
        /// * `output_dir` - Directory to save the downloaded file
        /// * `config` - Optional metadata configuration for tagging
        /// * `cancel` - Optional cancellation flag checked during the download
        ///
        /// # Returns
        ///
        /// The path to the downloaded file.
        pub fn download_track_cancellable(track_id: i32, format_id: i32, output_dir: &Path, config: Option<&MetadataConfig>) -> PathBuf = download_track,
        cancel: Option<&AtomicBool>
    );

    delegate_with_retry_cancellable!(
        /// Downloads all tracks in an album with cancellation support.
        ///
        /// # Arguments
        ///
        /// * `album_id` - Album identifier
        /// * `format_id` - Quality format ID
        /// * `output_dir` - Base output directory for downloaded files
        /// * `config` - Optional metadata configuration for tagging
        /// * `concurrency` - Maximum number of concurrent downloads
        /// * `cancel` - Optional cancellation flag
        ///
        /// # Returns
        ///
        /// A vector of paths to the downloaded track files.
        pub fn download_album_cancellable(album_id: &str, format_id: i32, output_dir: &Path, config: Option<&MetadataConfig>, concurrency: Option<usize>) -> Vec<PathBuf> = download_album,
        cancel: Option<Arc<AtomicBool>>
    );

    delegate_with_retry_cancellable!(
        /// Downloads all albums by an artist with cancellation support.
        ///
        /// # Arguments
        ///
        /// * `artist_id` - Artist identifier
        /// * `format_id` - Quality format ID
        /// * `output_dir` - Base output directory (subdirectories created per album)
        /// * `config` - Optional metadata configuration for tagging
        /// * `concurrency` - Maximum number of concurrent track downloads per album
        /// * `cancel` - Optional cancellation flag
        ///
        /// # Returns
        ///
        /// A vector of paths to all downloaded track files.
        pub fn download_artist_cancellable(artist_id: i32, format_id: i32, output_dir: &Path, config: Option<&MetadataConfig>, concurrency: Option<usize>) -> Vec<PathBuf> = download_artist,
        cancel: Option<Arc<AtomicBool>>
    );

    delegate_with_retry_cancellable!(
        /// Downloads all tracks in a playlist with cancellation support.
        ///
        /// # Arguments
        ///
        /// * `playlist_id` - Playlist identifier
        /// * `format_id` - Quality format ID
        /// * `output_dir` - Base output directory (playlist name subdirectory is created)
        /// * `config` - Optional metadata configuration for tagging
        /// * `cancel` - Optional cancellation flag
        ///
        /// # Returns
        ///
        /// A vector of paths to all downloaded track files.
        pub fn download_playlist_cancellable(playlist_id: &str, format_id: i32, output_dir: &Path, config: Option<&MetadataConfig>) -> Vec<PathBuf> = download_playlist,
        cancel: Option<Arc<AtomicBool>>
    );

    /// Downloads a single track with cancellation support.
    ///
    /// # Arguments
    ///
    /// * `track_id` - Track identifier
    /// * `format_id` - Quality format ID
    /// * `output_dir` - Directory to save the downloaded file
    /// * `config` - Optional metadata configuration for tagging
    /// * `cancel` - Optional cancellation flag checked during the download
    ///
    /// # Returns
    ///
    /// The path to the downloaded file.
    ///
    /// # Errors
    ///
    /// Returns a `QobuzApiError` if URL retrieval, download, I/O fails, or `Canceled` if cancelled.
    pub fn download_track(
        &mut self,
        track_id: i32,
        format_id: i32,
        output_dir: &Path,
        config: Option<&MetadataConfig>,
    ) -> Result<PathBuf, QobuzApiError> {
        self.download_track_cancellable(track_id, format_id, output_dir, config, None)
    }

    /// Downloads an album.
    ///
    /// # Arguments
    ///
    /// * `album_id` - Album identifier
    /// * `format_id` - Quality format ID
    /// * `output_dir` - Base output directory for downloaded files
    /// * `config` - Optional metadata configuration for tagging
    /// * `concurrency` - Maximum number of concurrent track downloads
    ///
    /// # Returns
    ///
    /// A vector of paths to the downloaded track files.
    ///
    /// # Errors
    ///
    /// Returns a `QobuzApiError` if album retrieval, track download, or I/O fails.
    pub fn download_album(
        &mut self,
        album_id: &str,
        format_id: i32,
        output_dir: &Path,
        config: Option<&MetadataConfig>,
        concurrency: Option<usize>,
    ) -> Result<Vec<PathBuf>, QobuzApiError> {
        self.download_album_cancellable(album_id, format_id, output_dir, config, concurrency, None)
    }

    /// Downloads all albums by an artist.
    ///
    /// # Arguments
    ///
    /// * `artist_id` - Artist identifier
    /// * `format_id` - Quality format ID
    /// * `output_dir` - Base output directory for downloaded files
    /// * `config` - Optional metadata configuration for tagging
    /// * `concurrency` - Maximum number of concurrent track downloads per album
    ///
    /// # Returns
    ///
    /// A vector of paths to the downloaded track files.
    ///
    /// # Errors
    ///
    /// Returns a `QobuzApiError` if artist release list retrieval or any album download fails.
    pub fn download_artist(
        &mut self,
        artist_id: i32,
        format_id: i32,
        output_dir: &Path,
        config: Option<&MetadataConfig>,
        concurrency: Option<usize>,
    ) -> Result<Vec<PathBuf>, QobuzApiError> {
        self.download_artist_cancellable(
            artist_id,
            format_id,
            output_dir,
            config,
            concurrency,
            None,
        )
    }

    /// Downloads all tracks in a playlist.
    ///
    /// # Arguments
    ///
    /// * `playlist_id` - Playlist identifier
    /// * `format_id` - Quality format ID
    /// * `output_dir` - Base output directory for downloaded files
    /// * `config` - Optional metadata configuration for tagging
    ///
    /// # Returns
    ///
    /// A vector of paths to the downloaded track files.
    ///
    /// # Errors
    ///
    /// Returns a `QobuzApiError` if playlist retrieval, directory creation, or any track download
    /// fails.
    pub fn download_playlist(
        &mut self,
        playlist_id: &str,
        format_id: i32,
        output_dir: &Path,
        config: Option<&MetadataConfig>,
    ) -> Result<Vec<PathBuf>, QobuzApiError> {
        self.download_playlist_cancellable(playlist_id, format_id, output_dir, config, None)
    }
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, atomic::AtomicBool};

    use {
        anyhow::{Result, ensure},
        tempfile::TempDir,
    };

    use crate::api::{
        content::quality::{FLAC_16_44, FLAC_24_96, MP3_320},
        dev_server::{MockServer, make_service, make_service_without_auth},
        service_state::QobuzApiService,
    };

    fn authenticated_service_with_dir() -> Result<(QobuzApiService, TempDir)> {
        let server = MockServer::start(200, "{}")?;
        let service = make_service(&server.base_url())?;
        let dir = TempDir::new()?;
        Ok((service, dir))
    }

    fn unauthenticated_service_with_dir() -> Result<(QobuzApiService, TempDir)> {
        let server = MockServer::start(200, "{}")?;
        let service = make_service_without_auth(&server.base_url())?;
        let dir = TempDir::new()?;
        Ok((service, dir))
    }

    #[test]
    fn get_track_file_url_requires_auth() -> Result<()> {
        let server = MockServer::start(200, "{}")?;
        let mut service = make_service_without_auth(&server.base_url())?;
        let result = service.get_track_file_url(123, MP3_320);
        ensure!(result.is_err(), "expected auth error without token");
        Ok(())
    }

    #[test]
    fn download_track_cancellable_returns_canceled() -> Result<()> {
        let (mut service, dir) = authenticated_service_with_dir()?;
        let cancel = AtomicBool::new(true);
        let result =
            service.download_track_cancellable(123, FLAC_16_44, dir.path(), None, Some(&cancel));
        ensure!(result.is_err(), "expected canceled error");
        Ok(())
    }

    #[test]
    fn download_track_requires_auth() -> Result<()> {
        let (mut service, dir) = unauthenticated_service_with_dir()?;
        let result = service.download_track(123, FLAC_24_96, dir.path(), None);
        ensure!(result.is_err(), "expected auth error without token");
        Ok(())
    }

    #[test]
    fn download_album_cancellable_returns_canceled() -> Result<()> {
        let (mut service, dir) = authenticated_service_with_dir()?;
        let cancel = Arc::new(AtomicBool::new(true));
        let result = service.download_album_cancellable(
            "abc",
            MP3_320,
            dir.path(),
            None,
            None,
            Some(cancel),
        );
        ensure!(result.is_err(), "expected canceled error");
        Ok(())
    }

    #[test]
    fn download_album_requires_auth() -> Result<()> {
        let (mut service, dir) = unauthenticated_service_with_dir()?;
        let result = service.download_album("abc", MP3_320, dir.path(), None, None);
        ensure!(result.is_err(), "expected auth error without token");
        Ok(())
    }

    #[test]
    fn download_artist_cancellable_returns_canceled() -> Result<()> {
        let (mut service, dir) = authenticated_service_with_dir()?;
        let cancel = Arc::new(AtomicBool::new(true));
        let result =
            service.download_artist_cancellable(42, MP3_320, dir.path(), None, None, Some(cancel));
        ensure!(result.is_err(), "expected canceled error");
        Ok(())
    }

    #[test]
    fn download_artist_requires_auth() -> Result<()> {
        let (mut service, dir) = unauthenticated_service_with_dir()?;
        let result = service.download_artist(42, MP3_320, dir.path(), None, None);
        ensure!(result.is_err(), "expected auth error without token");
        Ok(())
    }

    #[test]
    fn download_playlist_cancellable_returns_canceled() -> Result<()> {
        let (mut service, dir) = authenticated_service_with_dir()?;
        let cancel = Arc::new(AtomicBool::new(true));
        let result =
            service.download_playlist_cancellable("pl123", MP3_320, dir.path(), None, Some(cancel));
        ensure!(result.is_err(), "expected canceled error");
        Ok(())
    }

    #[test]
    fn download_playlist_requires_auth() -> Result<()> {
        let (mut service, dir) = unauthenticated_service_with_dir()?;
        let result = service.download_playlist("pl123", MP3_320, dir.path(), None);
        ensure!(result.is_err(), "expected auth error without token");
        Ok(())
    }
}