use clap::{CommandFactory, Parser}; use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; use std::{ fs::{self, File}, io, path::{Path, PathBuf}, process, }; /// Represents a record in the Exportify CSV format #[derive(Debug, Deserialize)] struct ExportifyRecord { #[serde(rename = "Track URI")] track_uri: Option, #[serde(rename = "Track Name")] track_name: Option, #[serde(rename = "Artist Name(s)")] artist_names: Option, #[serde(rename = "Album Name")] album_name: Option, #[serde(rename = "ISRC")] isrc: Option, } /// Represents a record in the `TuneMyMusic` CSV format #[derive(Debug, Serialize)] struct TuneMusicRecord { #[serde(rename = "Track name")] track_name: String, #[serde(rename = "Artist name")] artist_name: String, #[serde(rename = "Album")] album: String, #[serde(rename = "Playlist name")] playlist_name: String, #[serde(rename = "Type")] record_type: String, #[serde(rename = "ISRC")] isrc: String, #[serde(rename = "Spotify - id")] spotify_id: String, } /// CLI configuration parsed from command line arguments #[derive(Parser)] #[command(author, version, about = "Exportify to TuneMyMusic Converter", long_about = None)] struct Cli { /// Process all CSV files in a directory #[arg(short, long)] dir: bool, /// Path to Exportify CSV file or directory (with --dir) #[arg(value_name = "INPUT", required = false)] input: Option, /// Path to save converted `TuneMyMusic` CSV or directory (with --dir) #[arg(value_name = "OUTPUT", required = false)] output: Option, } /// Extracts a Spotify ID from a Track URI fn extract_spotify_id(uri: &str) -> Option { uri.split(':').nth(2).map(String::from) } /// Convert an Exportify record to a `TuneMyMusic` record fn convert_record( record: &ExportifyRecord, playlist_name: &str, ) -> TuneMusicRecord { // Extract Spotify ID from Track URI if available let spotify_id = record .track_uri .as_deref() .and_then(extract_spotify_id) .unwrap_or_default(); TuneMusicRecord { track_name: record.track_name.clone().unwrap_or_default(), artist_name: record.artist_names.clone().unwrap_or_default(), album: record.album_name.clone().unwrap_or_default(), playlist_name: playlist_name.to_string(), record_type: "Playlist".to_string(), isrc: record.isrc.clone().unwrap_or_default(), spotify_id, } } /// Read a CSV file into a string fn read_file(path: &Path) -> io::Result { fs::read_to_string(path) } /// Count non-empty lines in a file fn count_lines(content: &str) -> usize { content .lines() .filter(|line| !line.trim().is_empty()) .count() } /// Find all CSV files in a directory fn find_csv_files(dir_path: &Path) -> io::Result> { let entries = fs::read_dir(dir_path)?; let mut csv_files = Vec::new(); for entry in entries { let entry = entry?; let path = entry.path(); if path.is_file() && path.extension().is_some_and(|ext| { ext.to_string_lossy().to_lowercase() == "csv" }) { csv_files.push(path); } } Ok(csv_files) } /// Process a single file, converting from Exportify to `TuneMyMusic` format fn process_file( input_path: &Path, output_path: &Path, ) -> io::Result<(usize, usize)> { // Read the Exportify data let export_data = match read_file(input_path) { Ok(data) => data, Err(e) => { eprintln!("Error reading file {}: {e}", input_path.display()); return Err(e); }, }; // Parse the CSV data let mut rdr = ReaderBuilder::new() .has_headers(true) .trim(csv::Trim::All) .from_reader(export_data.as_bytes()); let export_records: Vec = match rdr.deserialize().collect() { Ok(records) => records, Err(e) => { eprintln!("Error parsing CSV {}: {e}", input_path.display()); return Err(io::Error::new(io::ErrorKind::InvalidData, e)); }, }; // Derive playlist name from input filename let playlist_name = input_path .file_stem() // Get filename without extension .map_or_else( || "misc.".to_string(), // Default if stem extraction fails |stem| stem.to_string_lossy().replace('_', " "), // Replace underscores with spaces ); // Convert to TuneMyMusic format let tune_records: Vec = export_records .iter() .map(|record| convert_record(record, &playlist_name)) // Pass playlist name .collect(); // Write to file let file = File::create(output_path)?; let mut wtr = WriterBuilder::new().from_writer(file); for record in &tune_records { if let Err(e) = wtr.serialize(record) { eprintln!( "Error writing record to {}: {e}", output_path.display() ); return Err(io::Error::new(io::ErrorKind::Other, e)); } } // Finish writing and flush the CSV writer if let Err(e) = wtr.flush() { eprintln!( "Error finalizing the CSV file {}: {e}", output_path.display() ); return Err(e); } Ok((export_records.len(), tune_records.len())) } /// Process all CSV files in a directory fn process_directory(input_dir: &Path, output_dir: &Path) -> io::Result<()> { // Ensure output directory exists if !output_dir.exists() { fs::create_dir_all(output_dir)?; } // Find all CSV files in the input directory let csv_files = match find_csv_files(input_dir) { Ok(files) => files, Err(e) => { eprintln!("Error reading directory {}: {e}", input_dir.display()); return Err(e); }, }; if csv_files.is_empty() { eprintln!("No CSV files found in {}", input_dir.display()); return Err(io::Error::new( io::ErrorKind::NotFound, "No CSV files found", )); } let num_csv_files = csv_files.len(); println!("Found {num_csv_files} CSV files to process"); let mut total_input_count = 0; let mut total_output_count = 0; // Process each CSV file for input_path in csv_files { let file_name = input_path.file_name().unwrap().to_string_lossy(); let output_file_name = format!("converted_{file_name}"); let output_path = output_dir.join(output_file_name); println!( "Processing: {} -> {}", input_path.display(), output_path.display() ); match process_file(&input_path, &output_path) { Ok((input_count, output_count)) => { println!(" Converted {output_count} tracks"); total_input_count += input_count; total_output_count += output_count; }, Err(e) => { eprintln!(" Error processing file: {e}"); continue; }, } } println!("\nConverted {num_csv_files} files"); println!("Total tracks processed: {total_input_count}"); println!("Total tracks converted: {total_output_count}"); Ok(()) } fn main() -> io::Result<()> { let cli = Cli::parse(); // Show help if no arguments provided if cli.input.is_none() && cli.output.is_none() && !cli.dir { let mut cmd = Cli::command(); cmd.print_help().unwrap(); println!(); return Ok(()); } // Get input and output paths with defaults let input = cli.input.unwrap_or_else(|| PathBuf::from("exportify.csv")); let output = cli .output .unwrap_or_else(|| PathBuf::from("converted_tunemymusic.csv")); // Check if input is a directory (either via --dir flag or auto-detection) let is_dir_mode = cli.dir || input.is_dir(); if is_dir_mode { // Directory mode processing let output_dir = if output.is_dir() || !output.exists() { output } else { eprintln!("Error: If input is a directory, output must also be a directory"); process::exit(1); }; process_directory(&input, &output_dir) } else { // Single file mode match process_file(&input, &output) { Ok((orig_count, track_count)) => { println!("Conversion complete. Saved to {}", output.display()); println!("Converted {track_count} tracks."); println!("\nValidation:"); println!("- Original tracks: {orig_count}"); // Read the output file for line counting let output_content = read_file(&output)?; let output_lines = count_lines(&output_content); let conv_count = output_lines - 1; // Subtract header println!("- Converted tracks: {conv_count}"); Ok(()) }, Err(e) => { eprintln!("Error during conversion: {e}"); process::exit(1); }, } } }