caesura 0.27.1

An all-in-one command line tool to transcode FLAC audio files and upload to gazelle based indexers/trackers
Documentation
use crate::prelude::*;
use gazelle_api::GazelleClientTrait;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;

/// Verify a FLAC source is suitable for transcoding.
#[injectable]
pub(crate) struct VerifyCommand {
    verify_options: Ref<VerifyOptions>,
    source_provider: Ref<SourceProvider>,
    api: Ref<Box<dyn GazelleClientTrait + Send + Sync>>,
    targets: Ref<TargetFormatProvider>,
    paths: Ref<PathManager>,
}

impl VerifyCommand {
    /// Execute [`VerifyCommand`] from the CLI.
    ///
    /// [`Source`] is retrieved from the CLI arguments.
    ///
    /// [`SourceIssue`] issues are logged as warnings.
    ///
    /// Returns `true` if the source is verified.
    pub(crate) async fn execute_cli(&self) -> Result<bool, Failure<VerifyAction>> {
        let source = match self.source_provider.get_from_options().await {
            Ok(Ok(source)) => source,
            Ok(Err(issue)) => {
                let status = VerifyStatus::from_issue(issue);
                warn!("{} for transcoding unknown", "Unsuitable".bold());
                if let Some(issues) = &status.issues {
                    for issue in issues {
                        warn!("{issue}");
                    }
                }
                return Ok(false);
            }
            Err(e) => return Err(Failure::new(VerifyAction::GetSource, e)),
        };
        let result = self.execute(&source).await;
        let id = source.to_string();
        if result.verified() {
            info!("{} {id}", "Verified".bold());
        } else {
            warn!("{} for transcoding {id}", "Unsuitable".bold());
            for issue in &result.issues {
                warn!("{issue}");
            }
        }
        Ok(result.verified())
    }

    /// Execute [`VerifyCommand`] on a [`Source`].
    ///
    /// Returns a [`VerifySuccess`] containing any issues found.
    pub(crate) async fn execute(&self, source: &Source) -> VerifySuccess {
        debug!("{} {}", "Verifying".bold(), source);
        let mut issues: Vec<SourceIssue> = Vec::new();
        issues.append(&mut self.api_checks(source));
        issues.append(&mut self.flac_checks(source));
        issues.append(&mut self.hash_check(source).await);
        VerifySuccess { issues }
    }

    /// Validate the source against the API.
    fn api_checks(&self, source: &Source) -> Vec<SourceIssue> {
        let mut issues: Vec<SourceIssue> = Vec::new();
        if source.group.category_name != "Music" {
            issues.push(SourceIssue::Category {
                actual: source.group.category_name.clone(),
            });
        }
        if source.torrent.scene {
            issues.push(SourceIssue::Scene);
        }
        if source.torrent.lossy_master_approved == Some(true) {
            issues.push(SourceIssue::LossyMaster);
        }
        if source.torrent.lossy_web_approved == Some(true) {
            issues.push(SourceIssue::LossyWeb);
        }
        if source.torrent.trumpable == Some(true) {
            issues.push(SourceIssue::Trumpable);
        }
        if source.torrent.remastered == Some(false) {
            issues.push(SourceIssue::Unconfirmed);
        }
        let excluded_tags: Vec<String> = self
            .verify_options
            .exclude_tags
            .clone()
            .unwrap_or_default()
            .into_iter()
            .filter(|x| source.group.tags.contains(x))
            .collect();
        if !excluded_tags.is_empty() {
            issues.push(SourceIssue::Excluded {
                tags: excluded_tags,
            });
        }
        let target_formats = self.targets.get(source.format, &source.existing);
        if target_formats.is_empty() {
            issues.push(SourceIssue::Existing {
                formats: source.existing.clone(),
            });
        }
        issues
    }

    #[allow(
        clippy::cast_sign_loss,
        clippy::cast_possible_wrap,
        clippy::as_conversions
    )]
    fn flac_checks(&self, source: &Source) -> Vec<SourceIssue> {
        if !source.directory.is_dir() {
            return vec![SourceIssue::MissingDirectory {
                path: source.directory.clone(),
            }];
        }
        let flacs = Collector::get_flacs_with_context(&source.directory);
        if flacs.is_empty() {
            return vec![SourceIssue::NoFlacs {
                path: source.directory.clone(),
            }];
        }
        let mut issues: Vec<SourceIssue> = Vec::new();
        let api_flacs = source.torrent.get_flacs();
        if flacs.len() != api_flacs.len() {
            issues.push(SourceIssue::FlacCount {
                expected: api_flacs.len(),
                actual: flacs.len(),
            });
        }

        issues.append(&mut VerifyCommand::subdirectory_checks(&flacs));

        let max_target = self
            .targets
            .get_max_path_length(source.format, &source.existing);
        let output_dir = self.paths.get_output_dir();
        let mut too_long = false;
        for flac in flacs {
            if let Some(max_target) = max_target {
                let path = self
                    .paths
                    .get_transcode_path(source, max_target, &flac)
                    .strip_prefix(output_dir.clone())
                    .expect("should be able to strip prefix from transcode path")
                    .to_path_buf();
                let length = path.to_string_lossy().chars().count() as isize;
                let excess = length - MAX_PATH_LENGTH;
                if excess > 0 {
                    let excess = excess as usize;
                    issues.push(SourceIssue::Length { path, excess });
                    Shortener::suggest_track_name(&flac);
                    too_long = true;
                }
            }
            let tags = TagVerifier::execute(&flac, source)
                .unwrap_or(vec!["failed to retrieve tags".to_owned()]);
            if !tags.is_empty() {
                issues.push(SourceIssue::MissingTags {
                    path: flac.path.clone(),
                    tags,
                });
            }
            for error in StreamVerifier::execute(&flac) {
                issues.push(error);
            }
        }
        if too_long {
            Shortener::suggest_album_name(source);
        }
        issues
    }

    async fn hash_check(&self, source: &Source) -> Vec<SourceIssue> {
        if self.verify_options.no_hash_check {
            debug!("{} hash check due to settings", "Skipped".bold());
            return Vec::new();
        }
        let torrent_path = self.paths.get_source_torrent_path(source);
        if !torrent_path.is_file() {
            trace!(
                "{} torrent file as it's not cached: {}",
                "Downloading".bold(),
                torrent_path.display()
            );
            let mut file = match File::create_new(&torrent_path).await {
                Ok(file) => file,
                Err(e) => {
                    return vec![SourceIssue::Error {
                        domain: "File System".to_owned(),
                        details: e.to_string(),
                    }];
                }
            };
            let buffer = match self.api.download_torrent(source.torrent.id).await {
                Ok(buffer) => buffer,
                #[expect(
                    deprecated,
                    reason = "SourceIssue::Api is kept for deserialization compatibility"
                )]
                Err(e) => return vec![SourceIssue::api(e.into())],
            };
            if let Err(e) = file.write_all(&buffer).await {
                return vec![SourceIssue::Error {
                    domain: "File System".to_owned(),
                    details: e.to_string(),
                }];
            }
            if let Err(e) = file.flush().await {
                return vec![SourceIssue::Error {
                    domain: "File System".to_owned(),
                    details: e.to_string(),
                }];
            }
        }
        TorrentVerifier::execute(&torrent_path, &source.directory)
            .await
            .unwrap_or_else(|e| {
                Some(SourceIssue::Error {
                    domain: "Torrent".to_owned(),
                    details: e.to_string(),
                })
            })
            .map_or_else(Vec::new, |x| vec![x])
    }

    pub fn subdirectory_checks(flacs: &[FlacFile]) -> Vec<SourceIssue> {
        // source.directory is the root directory of the torrent. If all flacs share a subdirectory
        // within that, it is unnecessary and trumpable. Multi-disc sets may separate items by
        // subdirs, so they will not be a common prefix.
        // Note that this is meant to verify the most common case, where a single unnecessary
        // directory contains all flac content, likely due to a misunderstanding of how the
        // creation tool works.
        let flac_sub_dirs: Vec<_> = flacs.iter().map(|x| &x.sub_dir).collect();
        if let Some(prefix) = Shortener::longest_common_prefix(&flac_sub_dirs) {
            return vec![SourceIssue::UnnecessaryDirectory { prefix }];
        }
        vec![]
    }
}