use crate::config::Config;
use crate::media::metadata::read_audio_metadata;
use crate::templates::{self, SidecarMetadata};
use crate::wav_metadata;
use indicatif::{MultiProgress, ProgressBar};
use owo_colors::OwoColorize;
use serde_yaml;
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use zim_studio::constants::AUDIO_EXTENSIONS;
use zim_studio::utils::parallel_scan;
use zim_studio::utils::progress::{create_progress_bar, create_progress_spinner};
use zim_studio::utils::project::find_project_root;
use zim_studio::utils::sidecar::get_sidecar_path;
use zim_studio::utils::validation::validate_path_exists;
use zim_studio::zimignore::ZimIgnore;
pub fn handle_update(project_path: &str, extra_tags: &[String]) -> Result<(), Box<dyn Error>> {
let project_path = Path::new(project_path);
validate_path_exists(project_path)?;
let config = Arc::new(Config::load()?);
println!(
"{} {}",
"Scanning project:".bright_black(),
project_path.display().to_string().cyan()
);
let audio_extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
let zimignore = ZimIgnore::load_for_directory(project_path);
let spinner = create_progress_spinner();
spinner.set_message("Scanning for audio files...");
let audio_files =
parallel_scan::collect_audio_files(project_path, &audio_extensions, &zimignore)?;
spinner.finish_and_clear();
let total_files = audio_files.len();
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 project_cache = Arc::new(Mutex::new(HashMap::<PathBuf, Option<String>>::new()));
let multi = MultiProgress::new();
let pb = multi.add(create_progress_bar(total_files as u64));
pb.set_message("Processing audio files...");
for file_path in &audio_files {
let result = process_media_file(
file_path,
&created_count,
&skipped_count,
&updated_count,
&pb,
&project_cache,
&config,
extra_tags,
);
if let Err(e) = result {
eprintln!("{} {}", "Error:".red(), e);
}
pb.inc(1);
}
pb.finish_with_message("Done");
let created = *created_count.lock().unwrap();
let updated = *updated_count.lock().unwrap();
let skipped = *skipped_count.lock().unwrap();
print_update_summary(created, updated, skipped, extra_tags);
Ok(())
}
fn extract_title_from_filename(filename: &str) -> String {
if let Some(dot_pos) = filename.rfind('.') {
filename[..dot_pos].to_string()
} else {
filename.to_string()
}
}
fn determine_file_type(file_path: &Path) -> Option<(String, String)> {
let components: Vec<&str> = file_path
.components()
.filter_map(|c| {
if let std::path::Component::Normal(s) = c {
s.to_str()
} else {
None
}
})
.collect();
for component in components.iter().rev().skip(1) {
let dir = component.to_lowercase();
let (singular, tag) = match dir.as_str() {
"mixes" => ("mix", "mix"),
"mix" => ("mix", "mix"),
"edits" => ("edit", "edit"),
"edit" => ("edit", "edit"),
"sources" => ("source", "source"),
"source" => ("source", "source"),
"recordings" => ("recording", "recording"),
"recording" => ("recording", "recording"),
"samples" => ("sample", "sample"),
"sample" => ("sample", "sample"),
"stems" => ("stem", "stem"),
"stem" => ("stem", "stem"),
"bounced" => ("bounce", "bounce"),
"bounce" => ("bounce", "bounce"),
"renders" => ("render", "render"),
"render" => ("render", "render"),
"masters" => ("master", "master"),
"master" => ("master", "master"),
"demos" => ("demo", "demo"),
"demo" => ("demo", "demo"),
"drafts" => ("draft", "draft"),
"draft" => ("draft", "draft"),
"ideas" => ("idea", "idea"),
"idea" => ("idea", "idea"),
"loops" => ("loop", "loop"),
"loop" => ("loop", "loop"),
"takes" => ("take", "take"),
"take" => ("take", "take"),
_ => {
continue;
}
};
return Some((singular.to_string(), tag.to_string()));
}
None
}
fn get_article(word: &str) -> &'static str {
match word.chars().next() {
Some('a' | 'e' | 'i' | 'o' | 'u') => "an",
Some('h') if word.to_lowercase().starts_with("hour") => "an",
_ => "a",
}
}
fn generate_description(file_type: Option<&str>, project: Option<&str>) -> String {
match (file_type, project) {
(Some(ft), Some(proj)) => {
let article = get_article(ft);
format!("{article} {ft} for {proj}")
}
(Some(ft), None) => {
let article = get_article(ft);
format!("{article} {ft}")
}
_ => String::new(),
}
}
#[allow(clippy::too_many_arguments)]
fn process_media_file(
file_path: &Path,
created: &Arc<Mutex<u32>>,
skipped: &Arc<Mutex<u32>>,
updated: &Arc<Mutex<u32>>,
pb: &ProgressBar,
project_cache: &Arc<Mutex<HashMap<PathBuf, Option<String>>>>,
config: &Arc<Config>,
extra_tags: &[String],
) -> 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() {
let audio_metadata = fs::metadata(file_path)?;
let sidecar_metadata = fs::metadata(&sidecar_path)?;
if let (Ok(audio_time), Ok(sidecar_time)) =
(audio_metadata.modified(), sidecar_metadata.modified())
&& audio_time > sidecar_time
{
pb.suspend(|| -> Result<(), Box<dyn Error>> {
if offer_metadata_update(file_path, &sidecar_path, updated)? {
Ok(())
} else {
touch_file(&sidecar_path)?;
*skipped.lock().unwrap() += 1;
Ok(())
}
})?;
return Ok(());
}
*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);
let (file_size, modified) = extract_file_metadata(file_path)?;
let project_name = {
let mut cache = project_cache.lock().unwrap();
let parent = file_path.parent().unwrap_or(Path::new("."));
cache
.entry(parent.to_path_buf())
.or_insert_with(|| find_project_root(file_path))
.clone()
};
let content = generate_sidecar_content(
file_path,
&file_name,
&relative_path.to_string_lossy(),
file_size,
modified.as_deref(),
project_name.as_deref(),
config,
extra_tags,
);
fs::write(&sidecar_path, content)?;
pb.set_message(format!("Created: {}", file_name.green()));
*created.lock().unwrap() += 1;
Ok(())
}
fn print_update_summary(created: u32, updated: u32, skipped: u32, extra_tags: &[String]) {
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()
);
if !extra_tags.is_empty() {
println!(
" {} {}",
"Extra tags:".bright_black(),
extra_tags.join(", ").cyan()
);
}
}
fn extract_file_metadata(path: &Path) -> Result<(u64, Option<String>), Box<dyn Error>> {
let metadata = std::fs::metadata(path)?;
let file_size = metadata.len();
let modified = 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());
Ok((file_size, modified))
}
#[allow(clippy::too_many_arguments)]
fn generate_sidecar_content(
file_path: &Path,
file_name: &str,
relative_path: &str,
file_size: u64,
modified: Option<&str>,
project: Option<&str>,
config: &Config,
extra_tags: &[String],
) -> String {
let title = extract_title_from_filename(file_name);
let file_type_info = determine_file_type(Path::new(relative_path));
let (file_type, tag) = file_type_info
.as_ref()
.map(|(t, tag)| (t.as_str(), tag.as_str()))
.unwrap_or(("", ""));
let description = generate_description(Some(file_type).filter(|s| !s.is_empty()), project);
let mut tags = if !tag.is_empty() {
vec![tag.to_string()]
} else {
vec![]
};
let filename_lower = file_name.to_lowercase();
for (pattern, tag_value) in &config.tag_mappings {
if filename_lower.contains(&pattern.to_lowercase()) && !tags.contains(tag_value) {
tags.push(tag_value.clone());
}
}
for tag in extra_tags {
if !tags.contains(tag) {
tags.push(tag.clone());
}
}
let extension = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
let uuid = if extension.as_deref() == Some("wav") {
let existing_metadata = wav_metadata::read_metadata(file_path).ok().flatten();
if let Some(metadata) = existing_metadata {
Some(metadata.uuid)
} else {
if let Ok(abs_path) = std::fs::canonicalize(file_path) {
let project_name = project.unwrap_or("unknown");
let mut new_metadata =
wav_metadata::ZimMetadata::new_original(project_name, &abs_path);
if let Ok(md5) = wav_metadata::calculate_audio_md5(file_path) {
new_metadata.audio_md5 = md5;
}
let temp_path = file_path.with_extension("wav.tmp");
if wav_metadata::write_metadata(file_path, &temp_path, &new_metadata).is_ok() {
if std::fs::rename(&temp_path, file_path).is_ok() {
Some(new_metadata.uuid)
} else {
let _ = std::fs::remove_file(&temp_path);
None
}
} else {
None
}
} else {
None
}
}
} else {
None
};
match extension.as_deref() {
Some("flac") | Some("wav") => {
match read_audio_metadata(file_path) {
Ok(metadata) => {
templates::generate_audio_sidecar_with_metadata(&SidecarMetadata {
file_name,
file_path: relative_path,
title: &title,
description: &description,
tags: &tags,
sample_rate: metadata.sample_rate,
channels: metadata.channels,
bits_per_sample: metadata.bits_per_sample,
duration_seconds: metadata.duration_seconds,
file_size,
modified,
project,
uuid: uuid.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,
&title,
&description,
&tags,
file_size,
modified,
project,
uuid.as_deref(),
)
}
}
}
_ => {
templates::generate_minimal_sidecar_with_fs_metadata(
file_name,
relative_path,
&title,
&description,
&tags,
file_size,
modified,
project,
None,
)
}
}
}
fn touch_file(path: &Path) -> Result<(), Box<dyn Error>> {
let now = SystemTime::now();
fs::File::options()
.create(true)
.truncate(false)
.write(true)
.open(path)?
.set_modified(now)?;
Ok(())
}
fn offer_metadata_update(
audio_path: &Path,
sidecar_path: &Path,
updated: &Arc<Mutex<u32>>,
) -> Result<bool, Box<dyn Error>> {
let file_name = audio_path.file_name().unwrap().to_string_lossy();
println!(
"\n{} Audio file '{}' is newer than its sidecar",
"⚠".yellow(),
file_name.cyan()
);
let sidecar_content = fs::read_to_string(sidecar_path)?;
let (yaml_data, markdown_content) = if sidecar_content.starts_with("---\n") {
let end_index = fs::read_to_string(sidecar_path)?[4..]
.find("\n---\n")
.ok_or("Invalid YAML frontmatter")?;
let yaml_content = &sidecar_content[4..4 + end_index];
let markdown = &sidecar_content[4 + end_index + 5..];
let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_content)?;
(yaml, markdown.to_string())
} else {
return Err("Sidecar file has no YAML frontmatter".into());
};
let (new_file_size, new_modified) = extract_file_metadata(audio_path)?;
let mut changes = Vec::new();
if let Some(old_size) = yaml_data.get("file_size").and_then(|v| v.as_u64())
&& old_size != new_file_size
{
changes.push(format!(
" file_size: {} → {}",
old_size.to_string().red(),
new_file_size.to_string().green()
));
}
if let Some(new_mod) = &new_modified
&& let Some(old_mod) = yaml_data.get("modified").and_then(|v| v.as_str())
&& old_mod != new_mod
{
changes.push(format!(
" modified: {} → {}",
old_mod.red(),
new_mod.green()
));
}
let extension = audio_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
let audio_metadata = match extension.as_deref() {
Some("flac") | Some("wav") => read_audio_metadata(audio_path).ok(),
_ => None,
};
if let Some(ref metadata) = audio_metadata {
if let Some(new_duration) = metadata.duration_seconds
&& let Some(old_duration) = yaml_data.get("duration").and_then(|v| v.as_f64())
&& (old_duration - new_duration).abs() > 0.01
{
changes.push(format!(
" duration: {} → {}",
format!("{old_duration:.2}").red(),
format!("{new_duration:.2}").green()
));
}
if let Some(old_rate) = yaml_data.get("sample_rate").and_then(|v| v.as_u64())
&& old_rate != metadata.sample_rate as u64
{
changes.push(format!(
" sample_rate: {} → {}",
old_rate.to_string().red(),
metadata.sample_rate.to_string().green()
));
}
if let Some(old_channels) = yaml_data.get("channels").and_then(|v| v.as_u64())
&& old_channels != metadata.channels as u64
{
changes.push(format!(
" channels: {} → {}",
old_channels.to_string().red(),
metadata.channels.to_string().green()
));
}
if let Some(old_bits) = yaml_data.get("bits_per_sample").and_then(|v| v.as_u64())
&& old_bits != metadata.bits_per_sample as u64
{
changes.push(format!(
" bits_per_sample: {} → {}",
old_bits.to_string().red(),
metadata.bits_per_sample.to_string().green()
));
}
}
if changes.is_empty() {
println!(" No metadata changes detected (timestamps differ but content is the same)");
print!(" Touch the sidecar file to update its timestamp? (y/n): ");
io::stdout().flush()?;
let mut response = String::new();
io::stdin().read_line(&mut response)?;
if response.trim().to_lowercase() == "y" {
touch_file(sidecar_path)?;
println!(" {} Touched sidecar file", "✓".green());
}
return Ok(false);
}
println!("\n Changes detected:");
for change in &changes {
println!("{change}");
}
print!("\n Update these fields in the sidecar? (y/n): ");
io::stdout().flush()?;
let mut response = String::new();
io::stdin().read_line(&mut response)?;
if response.trim().to_lowercase() == "y" {
let mut updated_yaml = yaml_data.clone();
updated_yaml.insert(
"file_size".to_string(),
serde_yaml::Value::Number(new_file_size.into()),
);
if let Some(new_mod) = new_modified {
updated_yaml.insert("modified".to_string(), serde_yaml::Value::String(new_mod));
}
if let Some(metadata) = audio_metadata {
if let Some(duration) = metadata.duration_seconds {
updated_yaml.insert(
"duration".to_string(),
serde_yaml::Value::Number(serde_yaml::Number::from(duration)),
);
}
updated_yaml.insert(
"sample_rate".to_string(),
serde_yaml::Value::Number(metadata.sample_rate.into()),
);
updated_yaml.insert(
"channels".to_string(),
serde_yaml::Value::Number(metadata.channels.into()),
);
updated_yaml.insert(
"bits_per_sample".to_string(),
serde_yaml::Value::Number(metadata.bits_per_sample.into()),
);
}
let yaml_string = serde_yaml::to_string(&updated_yaml)?;
let new_content = format!("---\n{yaml_string}---\n{markdown_content}");
fs::write(sidecar_path, new_content)?;
println!(" {} Updated metadata", "✓".green());
*updated.lock().unwrap() += 1;
Ok(true)
} else {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_should_skip_directory() {
assert!(parallel_scan::should_skip_directory("node_modules"));
assert!(parallel_scan::should_skip_directory(".git"));
assert!(parallel_scan::should_skip_directory("temp"));
assert!(!parallel_scan::should_skip_directory("src"));
assert!(!parallel_scan::should_skip_directory("audio"));
}
#[test]
fn test_is_hidden_file() {
assert!(parallel_scan::is_hidden_file(Path::new(".hidden")));
assert!(parallel_scan::is_hidden_file(Path::new("/path/.hidden")));
assert!(parallel_scan::is_hidden_file(Path::new(".DS_Store")));
assert!(!parallel_scan::is_hidden_file(Path::new("visible")));
assert!(!parallel_scan::is_hidden_file(Path::new("/path/visible")));
}
#[test]
fn test_audio_extensions() {
let extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
assert!(extensions.contains("wav"));
assert!(extensions.contains("flac"));
assert!(extensions.contains("mp3"));
assert!(extensions.contains("aiff"));
assert!(extensions.contains("m4a"));
assert_eq!(extensions.len(), 5);
}
#[test]
fn test_count_audio_files_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
let zimignore = ZimIgnore::new();
let files =
parallel_scan::collect_audio_files(temp_dir.path(), &extensions, &zimignore).unwrap();
assert_eq!(files.len(), 0);
}
#[test]
fn test_count_audio_files_with_audio() {
let temp_dir = TempDir::new().unwrap();
let extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
fs::write(temp_dir.path().join("test.wav"), b"fake").unwrap();
fs::write(temp_dir.path().join("test.flac"), b"fake").unwrap();
fs::write(temp_dir.path().join("test.mp3"), b"fake").unwrap();
fs::write(temp_dir.path().join("test.txt"), b"fake").unwrap();
fs::write(temp_dir.path().join("README.md"), b"fake").unwrap();
let zimignore = ZimIgnore::new();
let files =
parallel_scan::collect_audio_files(temp_dir.path(), &extensions, &zimignore).unwrap();
assert_eq!(files.len(), 3);
}
#[test]
fn test_count_audio_files_skip_hidden() {
let temp_dir = TempDir::new().unwrap();
let extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
fs::write(temp_dir.path().join("visible.wav"), b"fake").unwrap();
fs::write(temp_dir.path().join(".hidden.wav"), b"fake").unwrap();
let zimignore = ZimIgnore::new();
let files =
parallel_scan::collect_audio_files(temp_dir.path(), &extensions, &zimignore).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_count_audio_files_skip_directories() {
let temp_dir = TempDir::new().unwrap();
let extensions: HashSet<&str> = AUDIO_EXTENSIONS.iter().cloned().collect();
let normal_dir = temp_dir.path().join("music");
fs::create_dir(&normal_dir).unwrap();
fs::write(normal_dir.join("test.wav"), b"fake").unwrap();
let skip_dir = temp_dir.path().join("node_modules");
fs::create_dir(&skip_dir).unwrap();
fs::write(skip_dir.join("test.wav"), b"fake").unwrap();
let zimignore = ZimIgnore::new();
let files =
parallel_scan::collect_audio_files(temp_dir.path(), &extensions, &zimignore).unwrap();
assert_eq!(files.len(), 1); }
#[test]
fn test_extract_file_metadata() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, b"test content").unwrap();
let result = extract_file_metadata(&file_path);
assert!(result.is_ok());
let (size, modified) = result.unwrap();
assert_eq!(size, 12); assert!(modified.is_some());
assert!(modified.unwrap().contains("UTC"));
}
#[test]
fn test_touch_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
fs::write(&file_path, b"test content").unwrap();
let original_metadata = fs::metadata(&file_path).unwrap();
let original_modified = original_metadata.modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let result = touch_file(&file_path);
assert!(result.is_ok());
let new_metadata = fs::metadata(&file_path).unwrap();
let new_modified = new_metadata.modified().unwrap();
assert!(new_modified > original_modified);
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "test content");
}
#[test]
fn test_extract_title_from_filename() {
assert_eq!(extract_title_from_filename("song.wav"), "song");
assert_eq!(
extract_title_from_filename("my.great.song.flac"),
"my.great.song"
);
assert_eq!(
extract_title_from_filename("Final_Mix_v2.wav"),
"Final_Mix_v2"
);
assert_eq!(extract_title_from_filename("no_extension"), "no_extension");
assert_eq!(extract_title_from_filename(""), "");
}
#[test]
fn test_get_article() {
assert_eq!(get_article("edit"), "an");
assert_eq!(get_article("idea"), "an");
assert_eq!(get_article("audio"), "an");
assert_eq!(get_article("outro"), "an");
assert_eq!(get_article("underwater"), "an");
assert_eq!(get_article("mix"), "a");
assert_eq!(get_article("source"), "a");
assert_eq!(get_article("demo"), "a");
assert_eq!(get_article("sample"), "a");
assert_eq!(get_article(""), "a");
assert_eq!(get_article("hour"), "an"); assert_eq!(get_article("house"), "a");
assert_eq!(get_article("émigré"), "a"); }
#[test]
fn test_determine_file_type() {
use std::path::PathBuf;
assert_eq!(
determine_file_type(&PathBuf::from("mixes/final.wav")),
Some(("mix".to_string(), "mix".to_string()))
);
assert_eq!(
determine_file_type(&PathBuf::from("edits/intro.wav")),
Some(("edit".to_string(), "edit".to_string()))
);
assert_eq!(
determine_file_type(&PathBuf::from("sources/guitar.wav")),
Some(("source".to_string(), "source".to_string()))
);
assert_eq!(
determine_file_type(&PathBuf::from("samples/kick.wav")),
Some(("sample".to_string(), "sample".to_string()))
);
assert_eq!(
determine_file_type(&PathBuf::from("sources/day1/guitar.wav")),
Some(("source".to_string(), "source".to_string()))
);
assert_eq!(
determine_file_type(&PathBuf::from("mixes/old/2024/final.wav")),
Some(("mix".to_string(), "mix".to_string()))
);
assert_eq!(determine_file_type(&PathBuf::from("random/file.wav")), None);
assert_eq!(determine_file_type(&PathBuf::from("file.wav")), None);
assert_eq!(
determine_file_type(&PathBuf::from("project/stems/drums.wav")),
Some(("stem".to_string(), "stem".to_string()))
);
}
#[test]
fn test_generate_description() {
assert_eq!(
generate_description(Some("mix"), Some("my-project")),
"a mix for my-project"
);
assert_eq!(
generate_description(Some("edit"), Some("cool-song")),
"an edit for cool-song"
);
assert_eq!(generate_description(Some("source"), None), "a source");
assert_eq!(generate_description(None, Some("project")), "");
assert_eq!(generate_description(None, None), "");
}
}