use crate::media::metadata::read_audio_metadata;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use serde_yaml;
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use zim_studio::zimignore::ZimIgnore;
const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "aiff", "mp3", "m4a"];
const SKIP_DIRECTORIES: &[&str] = &["node_modules", ".git", "temp"];
pub fn handle_sync(project_path: &str) -> Result<(), Box<dyn Error>> {
let project_path = Path::new(project_path);
if !project_path.exists() {
return Err(format!(
"{} Path does not exist: {}",
"Error:".red().bold(),
project_path.display()
)
.into());
}
println!(
"{} {}",
"Syncing metadata in:".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 with sidecars...");
let files_to_sync = find_files_to_sync(project_path, &audio_extensions, &zimignore)?;
spinner.finish_and_clear();
if files_to_sync.is_empty() {
println!("{} No audio files with sidecars found", "⚠".yellow());
return Ok(());
}
println!(
"{} Found {} files to check for sync\n",
"ℹ".blue(),
files_to_sync.len().to_string().cyan().bold()
);
let synced_count = Arc::new(Mutex::new(0));
let skipped_count = Arc::new(Mutex::new(0));
let error_count = Arc::new(Mutex::new(0));
let multi = MultiProgress::new();
let pb = multi.add(create_progress_bar(files_to_sync.len() as u64));
pb.set_message("Syncing metadata...");
for (audio_path, sidecar_path) in &files_to_sync {
let file_name = audio_path.file_name().unwrap().to_string_lossy();
pb.set_message(format!("Checking: {file_name}"));
match sync_sidecar_metadata(audio_path, sidecar_path) {
Ok(true) => {
*synced_count.lock().unwrap() += 1;
pb.set_message(format!("Synced: {}", file_name.green()));
}
Ok(false) => {
*skipped_count.lock().unwrap() += 1;
pb.set_message(format!("Up to date: {}", file_name.bright_black()));
}
Err(e) => {
*error_count.lock().unwrap() += 1;
eprintln!(
" {} Failed to sync {}: {}",
"Error:".red(),
file_name.red(),
e.to_string().bright_black()
);
}
}
pb.inc(1);
}
pb.finish_with_message("Done");
let synced = *synced_count.lock().unwrap();
let skipped = *skipped_count.lock().unwrap();
let errors = *error_count.lock().unwrap();
print_sync_summary(synced, skipped, errors);
Ok(())
}
fn find_files_to_sync(
dir: &Path,
audio_exts: &HashSet<&str>,
zimignore: &ZimIgnore,
) -> Result<Vec<(PathBuf, PathBuf)>, Box<dyn Error>> {
let mut files_to_sync = Vec::new();
scan_for_sync(dir, audio_exts, zimignore, &mut files_to_sync)?;
Ok(files_to_sync)
}
fn scan_for_sync(
dir: &Path,
audio_exts: &HashSet<&str>,
zimignore: &ZimIgnore,
files_to_sync: &mut Vec<(PathBuf, PathBuf)>,
) -> Result<(), Box<dyn Error>> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if is_hidden_file(&path) {
continue;
}
if zimignore.is_ignored(&path, path.is_dir()) {
continue;
}
if path.is_dir() {
let dir_name = path.file_name().unwrap().to_string_lossy();
if should_skip_directory(&dir_name) {
continue;
}
scan_for_sync(&path, audio_exts, zimignore, files_to_sync)?;
} 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()) {
let sidecar_path = get_sidecar_path(&path);
if sidecar_path.exists() {
files_to_sync.push((path.clone(), sidecar_path));
}
}
}
}
}
Ok(())
}
fn sync_sidecar_metadata(audio_path: &Path, sidecar_path: &Path) -> Result<bool, Box<dyn Error>> {
let sidecar_content = fs::read_to_string(sidecar_path)?;
let file_metadata = fs::metadata(audio_path)?;
let file_size = file_metadata.len();
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());
let audio_metadata = read_audio_metadata(audio_path).ok();
let updated_content = update_yaml_fields(
&sidecar_content,
file_size,
modified.as_deref(),
audio_metadata.as_ref(),
)?;
if updated_content != sidecar_content {
fs::write(sidecar_path, updated_content)?;
Ok(true) } else {
Ok(false) }
}
fn update_yaml_fields(
content: &str,
file_size: u64,
modified: Option<&str>,
audio_metadata: Option<&crate::media::metadata::AudioMetadata>,
) -> Result<String, Box<dyn Error>> {
if !content.starts_with("---\n") {
return Ok(content.to_string()); }
let frontmatter_end = content[4..].find("\n---\n").map(|pos| pos + 4 + 5);
if frontmatter_end.is_none() {
return Ok(content.to_string()); }
let frontmatter_end = frontmatter_end.unwrap();
let yaml_section = &content[4..frontmatter_end - 5]; let markdown_section = &content[frontmatter_end..];
let mut yaml_data: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_section)?;
if let Some(meta) = audio_metadata {
if let Some(duration) = meta.duration_seconds {
yaml_data.insert(
"duration".to_string(),
serde_yaml::Value::Number(serde_yaml::Number::from(duration)),
);
}
yaml_data.insert(
"sample_rate".to_string(),
serde_yaml::Value::Number(meta.sample_rate.into()),
);
yaml_data.insert(
"channels".to_string(),
serde_yaml::Value::Number(meta.channels.into()),
);
yaml_data.insert(
"bit_depth".to_string(),
serde_yaml::Value::Number(meta.bits_per_sample.into()),
);
}
yaml_data.insert(
"file_size".to_string(),
serde_yaml::Value::Number(file_size.into()),
);
yaml_data.insert(
"modified".to_string(),
serde_yaml::Value::String(modified.unwrap_or("unknown").to_string()),
);
let updated_yaml = serde_yaml::to_string(&yaml_data)?;
Ok(format!("---\n{updated_yaml}---\n{markdown_section}"))
}
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
}
fn should_skip_directory(name: &str) -> bool {
SKIP_DIRECTORIES.contains(&name)
}
fn is_hidden_file(path: &Path) -> bool {
path.file_name()
.map(|name| name.to_string_lossy().starts_with('.'))
.unwrap_or(false)
}
fn create_progress_spinner() -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
spinner
}
fn create_progress_bar(total: u64) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("#>-"),
);
pb
}
fn print_sync_summary(synced: u32, skipped: u32, errors: u32) {
println!("\n{} {}", "✓".green().bold(), "Sync complete!".bold());
println!(
" {} {} files updated",
"Synced:".bright_black(),
synced.to_string().green().bold()
);
println!(
" {} {} files already up to date",
"Skipped:".bright_black(),
skipped.to_string().yellow().bold()
);
if errors > 0 {
println!(
" {} {} files had errors",
"Errors:".bright_black(),
errors.to_string().red().bold()
);
}
}