This commit is contained in:
Timofey Gelazoniya 2025-04-05 23:27:00 +03:00
commit 8bad90c982
Signed by: zeldon
SSH Key Fingerprint: SHA256:oPOHB2sFErY/e9bnGupIBTHB/MZYRPTdPQbX/zHgQuc
6 changed files with 781 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

305
Cargo.lock generated Normal file
View File

@ -0,0 +1,305 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "csv"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d"
dependencies = [
"memchr",
]
[[package]]
name = "ett"
version = "0.1.0"
dependencies = [
"clap",
"csv",
"serde",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

63
Cargo.toml Normal file
View File

@ -0,0 +1,63 @@
[package]
name = "ett"
version = "0.1.0"
edition = "2021"
license = "MIT"
authors = ["Timofey Gelazoniya <contact@zeldon.ru>"]
description = "Convert Exportify CSV files to TuneMyMusic CSV format"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
clone_on_ref_ptr = "deny"
disallowed_script_idents = "deny"
empty_enum_variants_with_brackets = "deny"
empty_structs_with_brackets = "deny"
enum_glob_use = "deny"
error_impl_error = "deny"
exit = "deny"
explicit_into_iter_loop = "deny"
explicit_iter_loop = "deny"
float_cmp_const = "deny"
if_then_some_else_none = "deny"
indexing_slicing = "deny"
lossy_float_literal = "deny"
map_err_ignore = "deny"
multiple_inherent_impl = "deny"
needless_raw_strings = "deny"
partial_pub_fields = "deny"
rc_buffer = "deny"
rc_mutex = "deny"
rest_pat_in_fully_bound_structs = "deny"
self_named_module_files = "deny"
semicolon_inside_block = "deny"
semicolon_outside_block = "deny"
string_slice = "deny"
string_to_string = "deny"
tests_outside_test_module = "deny"
try_err = "deny"
unnecessary_self_imports = "deny"
unneeded_field_pattern = "deny"
unseparated_literal_suffix = "deny"
verbose_file_reads = "deny"
complexity = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 }
style = { level = "deny", priority = -1 }
suspicious = { level = "deny", priority = -1 }
similar_names = "allow"
single_match_else = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
[dependencies]
csv = "1.2"
serde = { version = "1.0", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] }

109
README.md Normal file
View File

@ -0,0 +1,109 @@
# Exportify to TuneMyMusic Converter (ett)
A Rust CLI tool to convert Spotify playlist CSV exports from Exportify to TuneMyMusic CSV format.
## Features
- Convert CSV files from Exportify format to TuneMyMusic format
- Process single files or entire directories of CSV files
- Automatically detects when a directory is provided as input
- Detailed validation and statistics reporting
## Build from Source
1. Clone this repository:
```
git clone <repository-url>
cd exportify-to-tunemusic
```
2. Build the project:
```
cargo build --release
```
3. The binary will be available at `target/release/ett`
## Usage
### Single File Processing
Convert a single Exportify CSV file to TuneMyMusic format:
```
ett [INPUT_FILE] [OUTPUT_FILE]
```
Where:
- `[INPUT_FILE]` is the path to your Exportify CSV file (defaults to `exportify.csv`)
- `[OUTPUT_FILE]` is the path where the converted file will be saved (defaults to `converted_tunemymusic.csv`)
Example:
```
ett my_playlist.csv tunemymusic_output.csv
```
### Directory Processing
Process all CSV files in a directory:
```
ett --dir [INPUT_DIRECTORY] [OUTPUT_DIRECTORY]
```
or using the short flag:
```
ett -d [INPUT_DIRECTORY] [OUTPUT_DIRECTORY]
```
Where:
- `[INPUT_DIRECTORY]` is the directory containing Exportify CSV files
- `[OUTPUT_DIRECTORY]` is the directory where converted files will be saved
Example:
```
ett --dir ~/Downloads/playlists ~/converted_playlists
```
Note: You can also omit the `--dir` flag - the tool will automatically detect if the input is a directory:
```
ett ~/Downloads/playlists ~/converted_playlists
```
### Options
- `-d, --dir`: Process all CSV files in a directory
- `-h, --help`: Show help message
- `-V, --version`: Show version information
## Output Format
The output file will have the following columns, exactly matching TuneMyMusic's import format:
- Track name
- Artist name
- Album
- Playlist name
- Type
- ISRC
- Spotify - id
## Dependencies
This project uses the following crates:
- `csv`: For CSV reading and writing
- `serde`: For serialization/deserialization of CSV records
- `clap`: For command-line argument parsing with a modern API
## License
MIT

8
rustfmt.toml Normal file
View File

@ -0,0 +1,8 @@
hard_tabs = false
match_block_trailing_comma = true
max_width = 79
newline_style = "Unix"
reorder_imports = true
reorder_modules = true
use_field_init_shorthand = true
use_small_heuristics = "Default"

295
src/main.rs Normal file
View File

@ -0,0 +1,295 @@
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<String>,
#[serde(rename = "Track Name")]
track_name: Option<String>,
#[serde(rename = "Artist Name(s)")]
artist_names: Option<String>,
#[serde(rename = "Album Name")]
album_name: Option<String>,
#[serde(rename = "ISRC")]
isrc: Option<String>,
}
/// 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<PathBuf>,
/// Path to save converted `TuneMyMusic` CSV or directory (with --dir)
#[arg(value_name = "OUTPUT", required = false)]
output: Option<PathBuf>,
}
/// Extracts a Spotify ID from a Track URI
fn extract_spotify_id(uri: &str) -> Option<String> {
uri.split(':').nth(2).map(String::from)
}
/// Convert an Exportify record to a `TuneMyMusic` record
fn convert_record(record: &ExportifyRecord) -> 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: "misc.".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<String> {
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<Vec<PathBuf>> {
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<ExportifyRecord> =
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));
},
};
// Convert to TuneMyMusic format
let tune_records: Vec<TuneMusicRecord> =
export_records.iter().map(convert_record).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);
},
}
}
}