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::prelude::*;

/// Legacy output path from before platform user directories.
const LEGACY_OUTPUT_DIR: &str = "./output";

/// Options shared by all commands
#[derive(Options, Clone, Debug, Deserialize, Serialize)]
pub struct SharedOptions {
    /// Announce URL including passkey
    ///
    /// Examples: `https://flacsfor.me/a1b2c3d4e5f6/announce`, `https://home.opsfet.ch/a1b2c3d4e5f6/announce`
    #[arg(long)]
    #[options(required)]
    pub announce_url: String,

    /// API key with torrent permissions for the indexer.
    #[arg(long)]
    #[options(required)]
    pub api_key: String,

    /// ID of the tracker as it appears in the source field of a torrent.
    ///
    /// Examples: `red`, `pth`, `ops`
    #[arg(long)]
    #[options(required, default_fn = default_indexer, default_doc = "from announce_url")]
    pub indexer: String,

    /// URL of the indexer.
    ///
    /// Examples: `https://redacted.sh`, `https://orpheus.network`
    #[arg(long)]
    #[options(required, default_fn = default_indexer_url, default_doc = "from announce_url")]
    pub indexer_url: String,

    /// Directories containing torrent content.
    ///
    /// Typically this is set as the download directory in your torrent client.
    #[arg(long)]
    #[options(required, default_fn = default_content)]
    pub content: Vec<PathBuf>,

    /// Level of logs to display.
    #[arg(long, value_enum)]
    pub verbosity: Verbosity,

    /// Time format to use in logs.
    #[arg(long)]
    pub log_time: TimeFormat,

    /// Directory where transcodes and spectrograms will be written.
    #[arg(long)]
    #[options(default_fn = default_output, default_doc = "`~/.local/share/caesura/output/` or platform equivalent")]
    pub output: PathBuf,
}

#[expect(
    clippy::unnecessary_wraps,
    reason = "Options macro default_fn requires Option<T>"
)]
fn default_output(_partial: &SharedOptionsPartial) -> Option<PathBuf> {
    Some(PathManager::default_output_dir())
}

fn default_content(_partial: &SharedOptionsPartial) -> Option<Vec<PathBuf>> {
    is_docker().then(|| vec![PathBuf::from("/content")])
}

fn default_indexer(partial: &SharedOptionsPartial) -> Option<String> {
    match partial.announce_url.as_deref() {
        Some(url) if url.starts_with(RED_TRACKER_URL) => Some("red".to_owned()),
        Some(url) if url.starts_with(OPS_TRACKER_URL) => Some("ops".to_owned()),
        _ => None,
    }
}

fn default_indexer_url(partial: &SharedOptionsPartial) -> Option<String> {
    let indexer = partial.indexer.clone().or_else(|| default_indexer(partial));
    match indexer.as_deref() {
        Some("red") => Some(RED_URL.to_owned()),
        Some("ops") => Some(OPS_URL.to_owned()),
        _ => None,
    }
}

impl SharedOptions {
    /// Default indexer used by [`Self::mock()`] for testing.
    #[cfg(test)]
    pub const MOCK_INDEXER: &'static str = "red";

    /// Parse the raw [`Self::indexer`] field as an [`Indexer`].
    #[must_use]
    pub fn get_indexer(&self) -> Indexer {
        Indexer::from(self.indexer.as_str())
    }

    /// Output directory path with tilde expansion applied.
    #[must_use]
    pub fn output_path(&self) -> PathBuf {
        self.output.expand_tilde()
    }

    /// Content directory paths with tilde expansion applied.
    #[must_use]
    pub fn content_paths(&self) -> Vec<PathBuf> {
        self.content.iter().map(ExpandTilde::expand_tilde).collect()
    }

    /// Create a [`SharedOptions`] with mock values for testing.
    #[cfg(test)]
    pub fn mock() -> Self {
        Self {
            indexer: Self::MOCK_INDEXER.to_owned(),
            indexer_url: RED_URL.to_owned(),
            announce_url: format!("{RED_TRACKER_URL}/test/announce"),
            api_key: "test_api_key".to_owned(),
            ..SharedOptions::default()
        }
    }
}

impl OptionsContract for SharedOptions {
    type Partial = SharedOptionsPartial;

    fn validate(&self, validator: &mut OptionsValidator) {
        validator.check_url("indexer_url", &self.indexer_url);
        validator.check_url("announce_url", &self.announce_url);
        validator.check_non_empty("content", &self.content);
        for dir in self.content_paths() {
            validator.check_dir_exists("content", &dir);
        }
        let output = self.output_path();
        validator.check_dir_exists("output", &output);
        if !output.is_dir() && PathBuf::from(LEGACY_OUTPUT_DIR).is_dir() {
            let default_dir = PathManager::default_output_dir();
            validator.push(OptionIssue::default_changed(
                "output",
                &self.output.to_string_lossy(),
                &format!(
                    "In v0.27.0 the default output path changed to {}.\nPass the option: --output {LEGACY_OUTPUT_DIR} to use the previous output path.",
                    default_dir.display()
                ),
            ));
        }
    }
}