zahirscan 0.2.18

Token-efficient content compression for AI analysis using probabilistic template mining
Documentation
//! Audio file metadata extraction

mod mp3;

use anyhow::Result;
use lofty::{
    file::TaggedFileExt,
    picture::PictureType,
    read_from_path,
    tag::{Accessor, ItemKey},
};

use crate::config::RuntimeConfig;
use crate::parsers::media::image;
use crate::parsers::{CompressionMode, FileType, ParseResult, media_helpers};
use crate::results::{AudioMetadata as OutputAudioMetadata, ImageMetadata};
use crate::utils::{
    ffprobe_handler::{check_ffprobe_available, run_ffprobe_safe},
    filetypes::{get_extensions_for_file_type, is_codec_for_file_type},
};

/// Lossless audio codec extensions
/// Maps codec extensions to their compression mode
const LOSSLESS_CODECS: &[&str] = &["flac", "alac", "wavpack", "ape"];

/// Check if a codec string represents a lossless audio format
/// Verifies against extension map from tools.rs for consistency
pub(crate) fn is_lossless_codec(codec_ref: &str) -> bool {
    let audio_extensions = get_extensions_for_file_type(FileType::Audio);
    let codec_lower = codec_ref.to_lowercase();
    LOSSLESS_CODECS.iter().any(|lossless_ext| {
        codec_lower.contains(lossless_ext) && audio_extensions.contains(lossless_ext)
    })
}

/// Check if a string contains "opus" (case-insensitive)
/// Verifies against `FILE_EXTENSION_MAP` so we only treat known audio codecs as opus.
#[must_use]
pub fn is_opus_codec(s_ref: &str) -> bool {
    is_codec_for_file_type(s_ref, FileType::Audio) && s_ref.to_lowercase().contains("opus")
}

/// Rich tag metadata extracted from audio files
#[derive(Default)]
struct RichTags {
    title: Option<String>,
    artist: Option<String>,
    album: Option<String>,
    album_artist: Option<String>,
    track: Option<u32>,
    track_total: Option<u32>,
    year: Option<u32>,
    genre: Option<String>,
    comments: Option<String>,
    artwork: Option<ImageMetadata>,
}

fn extract_compression_mode(codec_ref: &str) -> CompressionMode {
    if is_lossless_codec(codec_ref) {
        CompressionMode::Lossless
    } else {
        CompressionMode::Lossy
    }
}

/// Extract audio metadata using ffprobe
///
/// # Errors
///
/// Returns [`anyhow::Error`] when `ffprobe` is missing, not executable, or fails to probe the file path.
pub fn extract_audio_metadata(
    _content_ref: &[u8],
    stats_ref: &ParseResult,
    _config_ref: &RuntimeConfig,
) -> Result<OutputAudioMetadata> {
    // Check if ffprobe is available before attempting extraction
    check_ffprobe_available()?;

    // Run ffprobe to get comprehensive metadata
    // Uses safe, hardcoded arguments via run_ffprobe_safe()
    let probe_result = run_ffprobe_safe(&stats_ref.file_path)?;

    // ============================================================================
    // Format-level metadata (container information)
    // ============================================================================
    let (container_format, duration_seconds, _bitrate) =
        media_helpers::extract_format_metadata(&probe_result);

    // ============================================================================
    // Find audio stream
    // ============================================================================
    let audio_stream = media_helpers::find_stream_by_type(&probe_result, "audio");

    // ============================================================================
    // Audio stream metadata extraction
    // ============================================================================
    let audio_metadata = media_helpers::extract_audio_stream_metadata(audio_stream);
    let audio_codec = audio_metadata.codec;
    let audio_channels = audio_metadata.channels;
    let audio_channel_layout = audio_metadata.channel_layout;
    let audio_sample_rate = audio_metadata.sample_rate;
    let audio_bitrate = audio_metadata.bitrate;
    let audio_codec_profile = media_helpers::extract_codec_profile(audio_stream);
    let audio_stream_size = media_helpers::calculate_stream_size(audio_bitrate, duration_seconds);

    // Compression mode - infer from codec (lossless: flac, alac, etc.; lossy: mp3, aac, etc.)
    let compression_mode = audio_codec
        .as_ref()
        .map(|codec| extract_compression_mode(codec));

    // Bit depth (bits per sample) - only meaningful for lossless formats
    // Lossy formats (MP3, AAC, Opus, etc.) don't have meaningful bit depth
    // as they use perceptual coding, not direct sample representation
    let bit_depth = if compression_mode == Some(CompressionMode::Lossless) {
        media_helpers::extract_stream_bit_depth(audio_stream)
    } else {
        None
    };

    // Encoded library (from stream tags)
    // Note: ffprobe crate 0.4.0 has limited tag access, so we check encoder field
    let encoded_library = media_helpers::extract_encoded_library(audio_stream, &probe_result);

    // Bit rate mode (CBR/VBR/ABR) - try LAME tag first, then encoder string or codec heuristics
    let bit_rate_mode = audio_codec
        .as_ref()
        .filter(|codec| mp3::is_mp3_codec(codec))
        .and_then(|_| mp3::read_lame_tag_bitrate_mode(&stats_ref.file_path))
        .or_else(|| {
            media_helpers::extract_bitrate_mode(
                audio_codec.as_ref(),
                encoded_library.as_ref(),
                None, // Not MP3, so no file path needed
            )
        });

    // Language and creation time (from tags)
    let audio_language = media_helpers::extract_language(audio_stream);
    let creation_time = media_helpers::extract_creation_time(audio_stream, &probe_result);

    // ============================================================================
    // Rich tag metadata using lofty (title, artist, album, track, track_total, year, genre, artwork)
    // ============================================================================
    let rich_tags = extract_rich_tags(&stats_ref.file_path);

    Ok(OutputAudioMetadata {
        duration_seconds,
        audio_codec,
        audio_codec_profile,
        audio_bitrate,
        audio_channels,
        audio_channel_layout,
        audio_sample_rate,
        audio_language,
        container_format,
        audio_stream_size,
        creation_time,
        title: rich_tags.title,
        artist: rich_tags.artist,
        album: rich_tags.album,
        album_artist: rich_tags.album_artist,
        track: rich_tags.track,
        track_total: rich_tags.track_total,
        year: rich_tags.year,
        genre: rich_tags.genre,
        comments: rich_tags.comments,
        bit_depth,
        compression_mode,
        encoded_library,
        bit_rate_mode,
        artwork: rich_tags.artwork,
    })
}

/// Extract rich tags (title, artist, album, track, `track_total`, year, genre, artwork) using lofty
fn extract_rich_tags(file_path_ref: &str) -> RichTags {
    // Try to read tags using lofty
    let tagged_file = match read_from_path(file_path_ref) {
        Ok(tf) => tf,
        Err(e) => {
            // Silently fail - not all files have tags, or lofty might not support the format
            log::debug!("Failed to read tags from {file_path_ref}: {e}");
            return RichTags::default();
        }
    };

    // Get the primary tag (most common/relevant tag for the format)
    let tag = match tagged_file.primary_tag() {
        Some(t) => t,
        None => {
            // Try to get the first available tag
            match tagged_file.first_tag() {
                Some(t) => t,
                None => {
                    return RichTags::default();
                }
            }
        }
    };

    // Extract common tag fields
    let title = tag.title().map(|s| s.to_string());
    let artist = tag.artist().map(|s| s.to_string());
    let album = tag.album().map(|s| s.to_string());

    // Track number (position) - already returns Option<u32>
    let track = tag.track();

    // Track total - returns Option<u32> in lofty 0.23
    let track_total = tag.track_total();

    // Year - lofty 0.23: date() returns Option<Timestamp>, Timestamp has .year (u16)
    let year = tag.date().map(|ts| u32::from(ts.year));

    // Genre - use ItemKey::Genre
    let genre = tag
        .get_string(ItemKey::Genre)
        .map(std::string::ToString::to_string);

    // Album artist - use ItemKey::AlbumArtist
    let album_artist = tag
        .get_string(ItemKey::AlbumArtist)
        .map(std::string::ToString::to_string);

    // Comments - use ItemKey::Comment
    let comments = tag
        .get_string(ItemKey::Comment)
        .map(std::string::ToString::to_string);

    // Extract and analyze artwork (cover art)
    let artwork = extract_artwork(tag);

    RichTags {
        title,
        artist,
        album,
        album_artist,
        track,
        track_total,
        year,
        genre,
        comments,
        artwork,
    }
}

/// Extract and analyze artwork/cover art from audio file tags
fn extract_artwork(tag_ref: &lofty::tag::Tag) -> Option<ImageMetadata> {
    // Look for front cover artwork
    for picture in tag_ref.pictures() {
        if picture.pic_type() == PictureType::CoverFront {
            // Get the image data
            let image_data = picture.data();

            // Use the existing image parser to analyze the artwork
            return analyze_image_data(image_data);
        }
    }

    // If no front cover found, try any cover type
    for picture in tag_ref.pictures() {
        if matches!(
            picture.pic_type(),
            PictureType::CoverFront
                | PictureType::CoverBack
                | PictureType::Other
                | PictureType::Artist
        ) {
            return analyze_image_data(picture.data());
        }
    }

    None
}

/// Analyze image data using the existing image parser
fn analyze_image_data(image_data_ref: &[u8]) -> Option<ImageMetadata> {
    // Create a minimal ParseResult for the image parser
    let stats = ParseResult {
        file_path: String::new(), // Not needed for image analysis
        file_type: FileType::Image,
        line_count: 0,
        byte_count: image_data_ref.len(),
        token_count: 0,
        duration: std::time::Duration::ZERO,
        is_binary: true,
        ..Default::default()
    };

    // Use the existing image parser
    let config = RuntimeConfig::default();
    match image::extract_image_metadata(image_data_ref, &stats, &config) {
        Ok(metadata) => Some(metadata),
        Err(e) => {
            log::debug!("Failed to extract artwork metadata: {e}");
            None
        }
    }
}

crate::no_template_mining!(
    extract_audio_templates,
    "Audio files don't have templates, return empty result."
);