caesura 0.31.0

An all-in-one command line tool to transcode FLAC audio files and upload to gazelle based indexers/trackers
Documentation
use crate::testing_prelude::*;

/// Builder for generating sample FLAC files.
///
/// # Example
/// ```ignore
/// // With automatic filename from metadata
/// FlacGenerator::new()
///     .with_artist("Artist Name")
///     .with_album("Album Name")
///     .with_title("Track Title")
///     .with_track_number("1")
///     .generate(&output_dir)
///     .await?;
///
/// // With explicit filename
/// FlacGenerator::new()
///     .with_filename("custom.flac")
///     .with_sample_rate(48000)
///     .generate(&output_dir)
///     .await?;
///
/// // With embedded cover image
/// FlacGenerator::new()
///     .with_cover_image()
///     .generate(&output_dir)
///     .await?;
/// ```
#[derive(Debug, Clone, Default)]
pub struct FlacGenerator {
    // Audio parameters
    sample_rate: Option<u32>,
    bit_depth: Option<u16>,
    channels: Option<u8>,
    duration_secs: Option<u32>,
    frequency: Option<u32>,

    // File location
    filename: Option<String>,
    sub_directory: Option<String>,

    // Metadata
    artist: Option<String>,
    album: Option<String>,
    title: Option<String>,
    track_number: Option<String>,
    disc_number: Option<String>,
    date: Option<String>,

    /// Additional Vorbis comment tags
    extra_tags: Vec<(String, String)>,

    /// Cover image
    embed_cover: bool,

    /// Skip writing Vorbis comments entirely.
    ///
    /// Produces a FLAC with only `STREAMINFO` + `SEEKTABLE`, simulating a
    /// file with no `VORBIS_COMMENT` block at all.
    omit_vorbis_comments: bool,
}

impl FlacGenerator {
    // Default values
    const DEFAULT_SAMPLE_RATE: u32 = 44100;
    const DEFAULT_BIT_DEPTH: u16 = 16;
    const DEFAULT_CHANNELS: u8 = 2;
    const DEFAULT_DURATION_SECS: u32 = 65;
    const DEFAULT_FREQUENCY: u32 = 440;

    /// Create a new FLAC generator with default audio parameters.
    ///
    /// Defaults:
    /// - Sample rate: 44100 Hz
    /// - Bit depth: 16
    /// - Channels: 2 (stereo)
    /// - Duration: 65 seconds
    /// - Frequency: 440 Hz (A4 note)
    /// - Filename: generated from metadata (e.g., "01 - Title.flac")
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a [`FlacGenerator`] with standard test metadata.
    #[must_use]
    pub fn mock() -> Self {
        Self::new()
            .with_artist("Test Artist")
            .with_album("Test Album")
            .with_title("Test Track")
            .with_track_number("1")
            .with_date("2000")
            .with_duration_secs(3)
    }

    /// Set an explicit filename (default: generated from metadata).
    #[must_use]
    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
        self.filename = Some(filename.into());
        self
    }

    /// Set the sample rate in Hz (default: 44100).
    #[must_use]
    pub fn with_sample_rate(mut self, rate: u32) -> Self {
        self.sample_rate = Some(rate);
        self
    }

    /// Set the bit depth (default: 16).
    #[must_use]
    pub fn with_bit_depth(mut self, depth: u16) -> Self {
        self.bit_depth = Some(depth);
        self
    }

    /// Set the number of channels (default: 2).
    #[must_use]
    #[expect(dead_code)]
    pub fn with_channels(mut self, channels: u8) -> Self {
        self.channels = Some(channels);
        self
    }

    /// Set the duration in seconds (default: 65).
    #[must_use]
    pub fn with_duration_secs(mut self, secs: u32) -> Self {
        self.duration_secs = Some(secs);
        self
    }

    /// Set the sine wave frequency in Hz (default: 440).
    #[must_use]
    pub fn with_frequency(mut self, freq: u32) -> Self {
        self.frequency = Some(freq);
        self
    }

    /// Set a sub-directory within the output directory.
    #[must_use]
    pub fn with_sub_directory(mut self, sub_dir: impl Into<String>) -> Self {
        self.sub_directory = Some(sub_dir.into());
        self
    }

    /// Set the ARTIST metadata tag.
    #[must_use]
    pub fn with_artist(mut self, artist: impl Into<String>) -> Self {
        self.artist = Some(artist.into());
        self
    }

    /// Set the ALBUM metadata tag.
    #[must_use]
    pub fn with_album(mut self, album: impl Into<String>) -> Self {
        self.album = Some(album.into());
        self
    }

    /// Set the TITLE metadata tag.
    #[must_use]
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Set the TRACKNUMBER metadata tag.
    #[must_use]
    pub fn with_track_number(mut self, track: impl Into<String>) -> Self {
        self.track_number = Some(track.into());
        self
    }

    /// Set the DISCNUMBER metadata tag if present.
    #[must_use]
    pub fn with_disc_number(mut self, disc: Option<impl Into<String>>) -> Self {
        self.disc_number = disc.map(Into::into);
        self
    }

    /// Set the DATE metadata tag.
    #[must_use]
    pub fn with_date(mut self, date: impl Into<String>) -> Self {
        self.date = Some(date.into());
        self
    }

    /// Add an arbitrary Vorbis comment tag.
    #[must_use]
    pub fn with_vorbis_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.extra_tags.push((key.into(), value.into()));
        self
    }

    /// Embed a cover image (100x100 gradient PNG).
    #[must_use]
    pub fn with_cover_image(mut self) -> Self {
        self.embed_cover = true;
        self
    }

    /// Skip writing Vorbis comments entirely.
    ///
    /// - Strips only the `VORBIS_COMMENT` block; combine with `with_cover` and a `PICTURE` block is still added
    #[must_use]
    pub fn omit_vorbis_comments(mut self) -> Self {
        self.omit_vorbis_comments = true;
        self
    }

    /// Build the filename from metadata.
    ///
    /// Format: "{`track_number`} - {title}.flac" or "{title}.flac" or "track.flac"
    fn build_filename(&self) -> String {
        match (&self.track_number, &self.title) {
            (Some(track), Some(title)) => format!("{track} - {title}.flac"),
            (None, Some(title)) => format!("{title}.flac"),
            (Some(track), None) => format!("{track} - track.flac"),
            (None, None) => "track.flac".to_owned(),
        }
    }

    /// Generate the FLAC file in the specified output directory.
    ///
    /// Returns the full path to the generated file.
    pub async fn generate(&self, output_dir: &Path) -> Result<PathBuf, Failure<SampleAction>> {
        let dir = match &self.sub_directory {
            Some(sub) => output_dir.join(sub),
            None => output_dir.to_path_buf(),
        };
        create_dir_all(&dir).map_err(Failure::wrap(SampleAction::CreateDirectory))?;
        let filename = self
            .filename
            .clone()
            .unwrap_or_else(|| self.build_filename());
        let path = dir.join(filename);
        let sample_rate = self.sample_rate.unwrap_or(Self::DEFAULT_SAMPLE_RATE);
        let bit_depth = self.bit_depth.unwrap_or(Self::DEFAULT_BIT_DEPTH);
        let channels = self.channels.unwrap_or(Self::DEFAULT_CHANNELS);
        let duration_secs = self.duration_secs.unwrap_or(Self::DEFAULT_DURATION_SECS);
        let frequency = self.frequency.unwrap_or(Self::DEFAULT_FREQUENCY);
        let is_sox_ng = detect_sox_ng();
        let binary = if is_sox_ng { SOX_NG } else { SOX };
        let mut command = TokioCommand::new(binary);
        if is_sox_ng {
            command.arg("--single-threaded");
        }
        command
            .args([
                "-D",
                "-n",
                "-r",
                &sample_rate.to_string(),
                "-b",
                &bit_depth.to_string(),
                "-c",
                &channels.to_string(),
            ])
            .arg(&path)
            .args([
                "synth",
                &duration_secs.to_string(),
                "sine",
                &frequency.to_string(),
            ])
            .run()
            .await
            .map_err(Failure::wrap(SampleAction::GenerateFlac))?;
        self.apply_metadata(&path).await?;
        if self.embed_cover {
            let image_path = ImageGenerator::new()
                .with_filename("cover_temp.png")
                .generate(&dir)?;
            self.apply_picture(&path, &image_path).await?;
            remove_file(&image_path).map_err(Failure::wrap(SampleAction::RemoveFile))?;
        }
        Ok(path)
    }

    async fn apply_metadata(&self, path: &Path) -> Result<(), Failure<SampleAction>> {
        if self.omit_vorbis_comments {
            TokioCommand::new(METAFLAC)
                .arg("--remove")
                .arg("--block-type=VORBIS_COMMENT")
                .arg("--dont-use-padding")
                .arg(path)
                .run()
                .await
                .map_err(Failure::wrap(SampleAction::SetTags))?;
            return Ok(());
        }
        let mut args: Vec<String> = Vec::new();
        if let Some(artist) = &self.artist {
            args.push(format!("--set-tag=ARTIST={artist}"));
        }
        if let Some(album) = &self.album {
            args.push(format!("--set-tag=ALBUM={album}"));
        }
        if let Some(title) = &self.title {
            args.push(format!("--set-tag=TITLE={title}"));
        }
        if let Some(track) = &self.track_number {
            args.push(format!("--set-tag=TRACKNUMBER={track}"));
        }
        if let Some(disc) = &self.disc_number {
            args.push(format!("--set-tag=DISCNUMBER={disc}"));
        }
        if let Some(date) = &self.date {
            args.push(format!("--set-tag=DATE={date}"));
        }
        for (key, value) in &self.extra_tags {
            args.push(format!("--set-tag={key}={value}"));
        }
        if args.is_empty() {
            return Ok(());
        }
        TokioCommand::new(METAFLAC)
            .args(&args)
            .arg(path)
            .run()
            .await
            .map_err(Failure::wrap(SampleAction::SetTags))?;
        Ok(())
    }

    async fn apply_picture(
        &self,
        flac_path: &Path,
        image_path: &Path,
    ) -> Result<(), Failure<SampleAction>> {
        // Format: [TYPE]|[MIME-TYPE]|[DESCRIPTION]|[WIDTHxHEIGHTxDEPTH[/COLORS]]|FILE
        // Type 3 = front cover
        // Leave width/height/depth empty for metaflac to auto-detect
        let spec = format!("3|image/png|||{}", image_path.display());
        TokioCommand::new(METAFLAC)
            .arg(format!("--import-picture-from={spec}"))
            .arg(flac_path)
            .run()
            .await
            .map_err(Failure::wrap(SampleAction::ImportPicture))?;
        Ok(())
    }
}