zim-studio 0.7.0

A Terminal-Based Audio Project Scaffold and Metadata System
Documentation
use crate::media::metadata::read_audio_metadata;
use crate::templates::{self, SidecarMetadata};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use std::collections::HashSet;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

pub fn handle_update(project_path: &str) -> Result<(), Box<dyn Error>> {
    let project_path = Path::new(project_path);

    // Verify this is a valid project directory
    if !project_path.exists() {
        return Err(format!(
            "{} Path does not exist: {}",
            "Error:".red().bold(),
            project_path.display()
        )
        .into());
    }

    println!(
        "{} {}",
        "Scanning project:".bright_black(),
        project_path.display().to_string().cyan()
    );

    // Get audio file extensions we want sidecars for
    let audio_extensions: HashSet<&str> = ["wav", "flac", "aiff", "mp3", "m4a"]
        .iter()
        .cloned()
        .collect();

    // First, count total audio files
    let spinner = ProgressBar::new_spinner();
    spinner.set_style(
        ProgressStyle::default_spinner()
            .template("{spinner:.cyan} {msg}")
            .unwrap()
            .tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
    );
    spinner.set_message("Counting audio files...");

    let total_files = count_audio_files(project_path, &audio_extensions)?;
    spinner.finish_and_clear();

    if total_files == 0 {
        println!("{} No audio files found in project", "".yellow());
        return Ok(());
    }

    println!(
        "{} Found {} audio files\n",
        "".blue(),
        total_files.to_string().cyan().bold()
    );

    let created_count = Arc::new(Mutex::new(0));
    let skipped_count = Arc::new(Mutex::new(0));
    let updated_count = Arc::new(Mutex::new(0));

    let multi = MultiProgress::new();
    let pb = multi.add(ProgressBar::new(total_files as u64));
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
            .unwrap()
            .progress_chars("█▓░"),
    );
    pb.set_message("Processing audio files...");

    // Walk the directory tree
    scan_directory(
        project_path,
        &audio_extensions,
        &created_count,
        &skipped_count,
        &updated_count,
        &pb,
    )?;

    pb.finish_with_message("Done");

    let created = *created_count.lock().unwrap();
    let updated = *updated_count.lock().unwrap();
    let skipped = *skipped_count.lock().unwrap();

    println!("\n{} {}", "".green().bold(), "Update complete!".bold());
    println!(
        "  {} {} new sidecar files",
        "Created:".bright_black(),
        created.to_string().green().bold()
    );
    println!(
        "  {} {} existing files",
        "Updated:".bright_black(),
        updated.to_string().blue().bold()
    );
    println!(
        "  {} {} files {}",
        "Skipped:".bright_black(),
        skipped.to_string().yellow().bold(),
        "(already have sidecars)".bright_black()
    );

    Ok(())
}

fn count_audio_files(dir: &Path, audio_exts: &HashSet<&str>) -> Result<u32, Box<dyn Error>> {
    let mut count = 0;
    let entries = fs::read_dir(dir)?;

    for entry in entries {
        let entry = entry?;
        let path = entry.path();

        if let Some(name) = path.file_name() {
            if name.to_string_lossy().starts_with('.') {
                continue;
            }
        }

        if path.is_dir() {
            let dir_name = path.file_name().unwrap().to_string_lossy();
            if dir_name == "node_modules" || dir_name == ".git" || dir_name == "temp" {
                continue;
            }
            count += count_audio_files(&path, audio_exts)?;
        } else if path.is_file() {
            if let Some(extension) = path.extension() {
                let ext = extension.to_string_lossy().to_lowercase();
                if audio_exts.contains(ext.as_str()) {
                    count += 1;
                }
            }
        }
    }

    Ok(count)
}

fn scan_directory(
    dir: &Path,
    audio_exts: &HashSet<&str>,
    created: &Arc<Mutex<u32>>,
    skipped: &Arc<Mutex<u32>>,
    updated: &Arc<Mutex<u32>>,
    pb: &ProgressBar,
) -> Result<(), Box<dyn Error>> {
    let entries = fs::read_dir(dir)?;

    for entry in entries {
        let entry = entry?;
        let path = entry.path();

        // Skip hidden files and directories
        if let Some(name) = path.file_name() {
            if name.to_string_lossy().starts_with('.') {
                continue;
            }
        }

        if path.is_dir() {
            // Skip certain directories
            let dir_name = path.file_name().unwrap().to_string_lossy();
            if dir_name == "node_modules" || dir_name == ".git" || dir_name == "temp" {
                continue;
            }

            // Recurse into subdirectory
            scan_directory(&path, audio_exts, created, skipped, updated, pb)?;
        } else if path.is_file() {
            // Check if this is an audio file
            if let Some(extension) = path.extension() {
                let ext = extension.to_string_lossy().to_lowercase();

                if audio_exts.contains(ext.as_str()) {
                    process_media_file(&path, created, skipped, updated, pb)?;
                    pb.inc(1);
                }
            }
        }
    }

    Ok(())
}

fn process_media_file(
    file_path: &Path,
    created: &Arc<Mutex<u32>>,
    skipped: &Arc<Mutex<u32>>,
    _updated: &Arc<Mutex<u32>>,
    pb: &ProgressBar,
) -> Result<(), Box<dyn Error>> {
    let sidecar_path = get_sidecar_path(file_path);

    let file_name = file_path.file_name().unwrap().to_string_lossy();

    if sidecar_path.exists() {
        *skipped.lock().unwrap() += 1;
        pb.set_message(format!("Skipped: {}", file_name.bright_black()));
        return Ok(());
    }
    let relative_path = file_path.strip_prefix(".").unwrap_or(file_path);

    // Get file system metadata
    let file_metadata = std::fs::metadata(file_path)?;
    let file_size = file_metadata.len();

    // Get modification time
    let modified = file_metadata
        .modified()
        .ok()
        .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
        .and_then(|duration| {
            chrono::DateTime::<chrono::Utc>::from_timestamp(duration.as_secs() as i64, 0)
        })
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string());

    // Try to read audio metadata
    let extension = file_path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_lowercase());

    let content = match extension.as_deref() {
        Some("flac") | Some("wav") => {
            match read_audio_metadata(file_path) {
                Ok(metadata) => {
                    // Generate sidecar with metadata
                    templates::generate_audio_sidecar_with_metadata(&SidecarMetadata {
                        file_name: &file_name,
                        file_path: &relative_path.to_string_lossy(),
                        sample_rate: metadata.sample_rate,
                        channels: metadata.channels,
                        bits_per_sample: metadata.bits_per_sample,
                        duration_seconds: metadata.duration_seconds,
                        file_size,
                        modified: modified.as_deref(),
                    })
                }
                Err(e) => {
                    eprintln!(
                        "  {} Could not read metadata from {}: {}",
                        "Warning:".yellow(),
                        file_name.yellow(),
                        e.to_string().bright_black()
                    );
                    templates::generate_minimal_sidecar_with_fs_metadata(
                        &file_name,
                        &relative_path.to_string_lossy(),
                        file_size,
                        modified.as_deref(),
                    )
                }
            }
        }
        _ => {
            // Unsupported audio format - create minimal sidecar
            templates::generate_minimal_sidecar_with_fs_metadata(
                &file_name,
                &relative_path.to_string_lossy(),
                file_size,
                modified.as_deref(),
            )
        }
    };

    fs::write(&sidecar_path, content)?;
    pb.set_message(format!("Created: {}", file_name.green()));
    *created.lock().unwrap() += 1;

    Ok(())
}

fn get_sidecar_path(media_path: &Path) -> PathBuf {
    let mut sidecar_path = media_path.to_path_buf();
    let current_name = sidecar_path.file_name().unwrap().to_string_lossy();
    let new_name = format!("{current_name}.md");
    sidecar_path.set_file_name(new_name);
    sidecar_path
}