commit 3159ee51adca363e4f2a38a6c4a4052839fe21bc Author: Connor Johnstone Date: Tue Mar 17 18:22:20 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..360fdc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +.env +*.db +*.db-journal diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d0b22a6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "shanty-org" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Music file organization and renaming for Shanty" +repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/org.git" + +[dependencies] +shanty-db = { path = "../shanty-db" } +sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tokio = { version = "1", features = ["full"] } +anyhow = "1" +lofty = "0.22" +walkdir = "2" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } +tempfile = "3" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8aa2665 --- /dev/null +++ b/readme.md @@ -0,0 +1,34 @@ +# shanty-org + +Music file organization and renaming for [Shanty](ssh://connor@git.rcjohnstone.com:2222/Shanty/shanty.git). + +Organizes music files into a clean directory structure based on metadata, +using configurable format templates. Works standalone (reading embedded tags) +or from the Shanty database. + +## Usage + +```sh +# Organize from a directory (standalone, reads tags from files) +shanty-org --source /music/messy --target /music/organized + +# Organize all tracks in the database +shanty-org --from-db --target /music/organized + +# Custom format template +shanty-org --source /music --target /music --format "{album_artist}/{album}/{disc_number}-{track_number} {title}.{ext}" + +# Dry run (preview only) +shanty-org --source /music/messy --target /music/organized --dry-run -vv + +# Copy instead of move +shanty-org --source /music/messy --target /music/organized --copy +``` + +## Default Format + +``` +{artist}/{album}/{track_number} - {title}.{ext} +``` + +Example: `Pink Floyd/The Dark Side of the Moon/03 - Time.flac` diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b320a34 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,27 @@ +use shanty_db::DbError; + +#[derive(Debug, thiserror::Error)] +pub enum OrgError { + #[error("database error: {0}")] + Db(#[from] DbError), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("metadata error: {0}")] + Metadata(String), + + #[error("walkdir error: {0}")] + WalkDir(#[from] walkdir::Error), + + #[error("{0}")] + Other(String), +} + +impl From for OrgError { + fn from(e: lofty::error::LoftyError) -> Self { + OrgError::Metadata(e.to_string()) + } +} + +pub type OrgResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6577041 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +//! Music file organization and renaming for Shanty. +//! +//! Organizes music files into a clean directory structure (e.g., +//! Artist/Album/Track) and renames them according to configurable templates. + +pub mod error; +pub mod metadata; +pub mod organizer; +pub mod sanitize; +pub mod template; + +pub use error::{OrgError, OrgResult}; +pub use organizer::{OrgConfig, OrgStats, organize_from_db, organize_from_directory}; +pub use template::DEFAULT_FORMAT; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1f52f80 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +use shanty_db::Database; +use shanty_org::{DEFAULT_FORMAT, OrgConfig, organize_from_db, organize_from_directory}; + +#[derive(Parser)] +#[command(name = "shanty-org", about = "Organize music files into a clean directory structure")] +struct Cli { + /// Source directory of music files (standalone mode, reads tags from files). + #[arg(long)] + source: Option, + + /// Organize all tracks known to the database. + #[arg(long)] + from_db: bool, + + /// Target root directory for organized files. + #[arg(long)] + target: PathBuf, + + /// Format template for directory structure and filenames. + #[arg(long, default_value = DEFAULT_FORMAT)] + format: String, + + /// Preview changes without moving files. + #[arg(long)] + dry_run: bool, + + /// Copy files instead of moving them. + #[arg(long)] + copy: bool, + + /// Database URL. Defaults to sqlite:///shanty/shanty.db?mode=rwc + #[arg(long, env = "SHANTY_DATABASE_URL")] + database: Option, + + /// Increase verbosity (-v info, -vv debug, -vvv trace). + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, +} + +fn default_database_url() -> String { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty"); + std::fs::create_dir_all(&data_dir).ok(); + let db_path = data_dir.join("shanty.db"); + format!("sqlite://{}?mode=rwc", db_path.display()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "warn", + 1 => "info,shanty_org=info", + 2 => "info,shanty_org=debug", + _ => "debug,shanty_org=trace", + }; + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), + ) + .init(); + + if !cli.from_db && cli.source.is_none() { + anyhow::bail!("specify either --from-db or --source "); + } + if cli.from_db && cli.source.is_some() { + anyhow::bail!("--from-db and --source are mutually exclusive"); + } + + let config = OrgConfig { + target_dir: cli.target, + format: cli.format, + dry_run: cli.dry_run, + copy: cli.copy, + }; + + if config.dry_run { + println!("DRY RUN — no files will be moved"); + } + + let stats = if cli.from_db { + let database_url = cli.database.unwrap_or_else(default_database_url); + tracing::info!(url = %database_url, "connecting to database"); + let db = Database::new(&database_url).await?; + organize_from_db(db.conn(), &config).await? + } else { + let source = cli.source.unwrap(); + if !source.is_dir() { + anyhow::bail!("'{}' is not a directory", source.display()); + } + organize_from_directory(&source, &config).await? + }; + + println!("\nOrganization complete: {stats}"); + Ok(()) +} diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000..4551ddc --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,82 @@ +use std::path::Path; + +use lofty::config::ParseOptions; +use lofty::file::TaggedFileExt; +use lofty::probe::Probe; +use lofty::tag::Accessor; + +use crate::error::OrgResult; + +/// Metadata needed for organizing a track. Can be populated from the DB or from file tags. +#[derive(Debug, Clone, Default)] +pub struct TrackMetadata { + pub artist: Option, + pub album_artist: Option, + pub album: Option, + pub title: Option, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub genre: Option, + pub ext: String, +} + +impl TrackMetadata { + /// Build from a shanty-db track model. + pub fn from_db_track(track: &shanty_db::entities::track::Model) -> Self { + let ext = Path::new(&track.file_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(); + + Self { + artist: track.artist.clone(), + album_artist: track.album_artist.clone(), + album: track.album.clone(), + title: track.title.clone(), + track_number: track.track_number, + disc_number: track.disc_number, + year: track.year, + genre: track.genre.clone(), + ext, + } + } + + /// Build by reading embedded tags from a music file. + pub fn from_file(path: &Path) -> OrgResult { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(); + + let tagged_file = Probe::open(path)? + .options(ParseOptions::default()) + .read()?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let mut meta = TrackMetadata { + ext, + ..Default::default() + }; + + if let Some(tag) = tag { + meta.title = tag.title().map(|s| s.to_string()); + meta.artist = tag.artist().map(|s| s.to_string()); + meta.album = tag.album().map(|s| s.to_string()); + meta.genre = tag.genre().map(|s| s.to_string()); + meta.track_number = tag.track().map(|n| n as i32); + meta.disc_number = tag.disk().map(|n| n as i32); + meta.year = tag.year().map(|n| n as i32); + meta.album_artist = tag + .get_string(&lofty::tag::ItemKey::AlbumArtist) + .map(|s| s.to_string()); + } + + Ok(meta) + } +} diff --git a/src/organizer.rs b/src/organizer.rs new file mode 100644 index 0000000..48a63d9 --- /dev/null +++ b/src/organizer.rs @@ -0,0 +1,355 @@ +use std::collections::HashSet; +use std::fmt; +use std::path::{Path, PathBuf}; + +use sea_orm::{ActiveValue::Set, DatabaseConnection}; +use walkdir::WalkDir; + +use shanty_db::queries; + +use crate::error::OrgResult; +use crate::metadata::TrackMetadata; +use crate::template; + +const MUSIC_EXTENSIONS: &[&str] = &[ + "mp3", "flac", "ogg", "opus", "m4a", "wav", "wma", "aac", "alac", +]; + +/// Configuration for an organize operation. +pub struct OrgConfig { + /// Root directory for organized output. + pub target_dir: PathBuf, + /// Format template string. + pub format: String, + /// If true, preview changes without moving files. + pub dry_run: bool, + /// If true, copy files instead of moving them. + pub copy: bool, +} + +/// Statistics from a completed organize operation. +#[derive(Debug, Default, Clone)] +pub struct OrgStats { + pub files_found: u64, + pub files_organized: u64, + pub files_skipped: u64, + pub files_errored: u64, +} + +impl fmt::Display for OrgStats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "found: {}, organized: {}, skipped: {}, errors: {}", + self.files_found, self.files_organized, self.files_skipped, self.files_errored, + ) + } +} + +/// Resolve the target path, handling conflicts by appending a number. +fn resolve_target(target: &Path) -> PathBuf { + if !target.exists() { + return target.to_owned(); + } + + let stem = target + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let ext = target + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let parent = target.parent().unwrap_or(Path::new(".")); + + for i in 2..=999 { + let new_name = if ext.is_empty() { + format!("{stem} ({i})") + } else { + format!("{stem} ({i}).{ext}") + }; + let candidate = parent.join(new_name); + if !candidate.exists() { + return candidate; + } + } + + // Extremely unlikely fallback + target.to_owned() +} + +/// Move or copy a file, creating parent directories as needed. +fn move_or_copy(source: &Path, target: &Path, copy: bool) -> OrgResult<()> { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + + if copy { + std::fs::copy(source, target)?; + tracing::info!( + from = %source.display(), + to = %target.display(), + "copied" + ); + } else { + // Try rename first (fast, same filesystem) + if std::fs::rename(source, target).is_err() { + // Cross-filesystem: copy then delete + std::fs::copy(source, target)?; + std::fs::remove_file(source)?; + } + tracing::info!( + from = %source.display(), + to = %target.display(), + "moved" + ); + } + + Ok(()) +} + +/// Remove empty directories starting from `dir` and walking up, stopping at `stop_at`. +fn cleanup_empty_dirs(dir: &Path, stop_at: &Path) { + let mut current = dir.to_owned(); + while current != stop_at && current.starts_with(stop_at) { + match std::fs::read_dir(¤t) { + Ok(mut entries) => { + if entries.next().is_none() { + // Directory is empty + if let Err(e) = std::fs::remove_dir(¤t) { + tracing::debug!(path = %current.display(), "failed to remove empty dir: {e}"); + break; + } + tracing::debug!(path = %current.display(), "removed empty directory"); + } else { + break; // Not empty + } + } + Err(_) => break, + } + current = match current.parent() { + Some(p) => p.to_owned(), + None => break, + }; + } +} + +/// Organize all tracks from the database. +pub async fn organize_from_db( + conn: &DatabaseConnection, + config: &OrgConfig, +) -> OrgResult { + let mut stats = OrgStats::default(); + let mut source_dirs: HashSet = HashSet::new(); + + // Fetch all tracks (paginated) + let mut offset = 0u64; + let page_size = 500u64; + + loop { + let tracks = queries::tracks::list(conn, page_size, offset).await?; + if tracks.is_empty() { + break; + } + + for track in &tracks { + stats.files_found += 1; + let source = Path::new(&track.file_path); + + if !source.exists() { + tracing::warn!(path = %track.file_path, "source file does not exist, skipping"); + stats.files_errored += 1; + continue; + } + + let meta = TrackMetadata::from_db_track(track); + let relative = template::render(&config.format, &meta); + let target = config.target_dir.join(&relative); + + // Canonicalize for comparison + let source_canon = source.canonicalize().unwrap_or_else(|_| source.to_owned()); + let target_parent = target.parent().unwrap_or(Path::new(".")); + if target_parent.exists() { + let target_canon = target_parent + .canonicalize() + .map(|p| p.join(target.file_name().unwrap_or_default())) + .unwrap_or_else(|_| target.clone()); + if source_canon == target_canon { + tracing::debug!(path = %track.file_path, "already in place, skipping"); + stats.files_skipped += 1; + continue; + } + } + + let final_target = resolve_target(&target); + + if config.dry_run { + println!("{} → {}", source.display(), final_target.display()); + stats.files_organized += 1; + continue; + } + + // Remember source directory for cleanup + if let Some(parent) = source.parent() { + source_dirs.insert(parent.to_owned()); + } + + match move_or_copy(source, &final_target, config.copy) { + Ok(()) => { + // Update file_path in DB + let new_path = final_target.to_string_lossy().to_string(); + let active = shanty_db::entities::track::ActiveModel { + id: Set(track.id), + file_path: Set(new_path), + ..Default::default() + }; + if let Err(e) = queries::tracks::update_metadata(conn, track.id, active).await { + tracing::error!(id = track.id, error = %e, "failed to update DB path"); + } + stats.files_organized += 1; + } + Err(e) => { + tracing::error!( + from = %source.display(), + to = %final_target.display(), + error = %e, + "failed to organize file" + ); + stats.files_errored += 1; + } + } + } + + offset += page_size; + } + + // Cleanup empty directories + if !config.dry_run && !config.copy { + for dir in &source_dirs { + cleanup_empty_dirs(dir, dir); + } + } + + tracing::info!(%stats, "organization complete"); + Ok(stats) +} + +/// Organize music files from a source directory (standalone, no database). +pub async fn organize_from_directory( + source_dir: &Path, + config: &OrgConfig, +) -> OrgResult { + let mut stats = OrgStats::default(); + let source_root = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.to_owned()); + + // Collect music files + let files: Vec = WalkDir::new(source_dir) + .follow_links(true) + .into_iter() + .filter_map(|entry| { + let entry = entry.ok()?; + if !entry.file_type().is_file() { + return None; + } + let ext = entry + .path() + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase())?; + if MUSIC_EXTENSIONS.contains(&ext.as_str()) { + Some(entry.path().to_owned()) + } else { + None + } + }) + .collect(); + + stats.files_found = files.len() as u64; + tracing::info!(count = stats.files_found, "found music files"); + + for source in &files { + let meta = match TrackMetadata::from_file(source) { + Ok(m) => m, + Err(e) => { + tracing::warn!(path = %source.display(), error = %e, "failed to read metadata"); + stats.files_errored += 1; + continue; + } + }; + + let relative = template::render(&config.format, &meta); + let target = config.target_dir.join(&relative); + + // Skip if already in place + let source_canon = source.canonicalize().unwrap_or_else(|_| source.clone()); + let target_parent = target.parent().unwrap_or(Path::new(".")); + if target_parent.exists() { + let target_canon = target_parent + .canonicalize() + .map(|p| p.join(target.file_name().unwrap_or_default())) + .unwrap_or_else(|_| target.clone()); + if source_canon == target_canon { + stats.files_skipped += 1; + continue; + } + } + + let final_target = resolve_target(&target); + + if config.dry_run { + println!("{} → {}", source.display(), final_target.display()); + stats.files_organized += 1; + continue; + } + + match move_or_copy(source, &final_target, config.copy) { + Ok(()) => { + stats.files_organized += 1; + } + Err(e) => { + tracing::error!( + from = %source.display(), + to = %final_target.display(), + error = %e, + "failed to organize file" + ); + stats.files_errored += 1; + } + } + } + + // Cleanup empty directories + if !config.dry_run && !config.copy { + cleanup_empty_dirs_recursive(&source_root); + } + + tracing::info!(%stats, "organization complete"); + Ok(stats) +} + +/// Walk a directory bottom-up and remove all empty directories. +fn cleanup_empty_dirs_recursive(root: &Path) { + // Collect directories depth-first + let dirs: Vec = WalkDir::new(root) + .contents_first(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_dir()) + .map(|e| e.path().to_owned()) + .collect(); + + for dir in dirs { + if dir == root { + continue; + } + if let Ok(mut entries) = std::fs::read_dir(&dir) { + if entries.next().is_none() { + if std::fs::remove_dir(&dir).is_ok() { + tracing::debug!(path = %dir.display(), "removed empty directory"); + } + } + } + } +} diff --git a/src/sanitize.rs b/src/sanitize.rs new file mode 100644 index 0000000..8df6a26 --- /dev/null +++ b/src/sanitize.rs @@ -0,0 +1,80 @@ +/// Characters that are invalid in filenames on common filesystems. +const INVALID_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']; + +/// Sanitize a single path component (filename or directory name) for filesystem safety. +pub fn sanitize_component(s: &str) -> String { + let mut result: String = s + .chars() + .map(|c| if INVALID_CHARS.contains(&c) { '_' } else { c }) + .collect(); + + // Trim leading/trailing dots and spaces (problematic on Windows and some Linux tools) + result = result.trim_matches(|c: char| c == '.' || c == ' ').to_string(); + + // Collapse consecutive underscores + while result.contains("__") { + result = result.replace("__", "_"); + } + + // Truncate to 255 bytes (common filesystem limit) + if result.len() > 255 { + result = result[..255].to_string(); + // Don't leave a partial UTF-8 sequence + while !result.is_char_boundary(result.len()) { + result.pop(); + } + } + + // If empty after sanitization, return a placeholder + if result.is_empty() { + return "_".to_string(); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_normal() { + assert_eq!(sanitize_component("Hello World"), "Hello World"); + } + + #[test] + fn test_sanitize_invalid_chars() { + assert_eq!(sanitize_component("AC/DC"), "AC_DC"); + assert_eq!(sanitize_component("What?"), "What_"); + assert_eq!(sanitize_component("a:b*c"), "a_b_c"); + } + + #[test] + fn test_sanitize_dots_and_spaces() { + assert_eq!(sanitize_component("..hidden"), "hidden"); + assert_eq!(sanitize_component(" spaced "), "spaced"); + assert_eq!(sanitize_component("..."), "_"); + } + + #[test] + fn test_sanitize_collapse_underscores() { + assert_eq!(sanitize_component("a///b"), "a_b"); + } + + #[test] + fn test_sanitize_empty() { + assert_eq!(sanitize_component(""), "_"); + } + + #[test] + fn test_sanitize_null_bytes() { + assert_eq!(sanitize_component("a\0b"), "a_b"); + } + + #[test] + fn test_sanitize_long_string() { + let long = "a".repeat(300); + let result = sanitize_component(&long); + assert!(result.len() <= 255); + } +} diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..9c5f5df --- /dev/null +++ b/src/template.rs @@ -0,0 +1,141 @@ +use crate::metadata::TrackMetadata; +use crate::sanitize::sanitize_component; + +/// Default format template. +pub const DEFAULT_FORMAT: &str = "{artist}/{album}/{track_number} - {title}.{ext}"; + +/// Render a format template with track metadata. +/// +/// Variables are substituted and each path component is sanitized for filesystem safety. +/// The template may contain `/` to create subdirectories. +pub fn render(template: &str, meta: &TrackMetadata) -> String { + let artist = meta + .album_artist + .as_deref() + .or(meta.artist.as_deref()) + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown Artist"); + + let album_artist = meta + .album_artist + .as_deref() + .or(meta.artist.as_deref()) + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown Artist"); + + let album = meta + .album + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown Album"); + + let title = meta + .title + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown Title"); + + let track_number = format!("{:02}", meta.track_number.unwrap_or(0)); + let disc_number = format!("{}", meta.disc_number.unwrap_or(1)); + let year = format!("{}", meta.year.unwrap_or(0)); + + let genre = meta + .genre + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("Unknown Genre"); + + let ext = &meta.ext; + + // Split template by "/" so we can sanitize each path component individually. + // The extension is special — it's part of the filename, not sanitized separately. + let result = template + .replace("{artist}", &sanitize_component(artist)) + .replace("{album_artist}", &sanitize_component(album_artist)) + .replace("{album}", &sanitize_component(album)) + .replace("{title}", &sanitize_component(title)) + .replace("{track_number}", &track_number) + .replace("{disc_number}", &disc_number) + .replace("{year}", &year) + .replace("{genre}", &sanitize_component(genre)) + .replace("{ext}", ext); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_meta() -> TrackMetadata { + TrackMetadata { + artist: Some("Pink Floyd".into()), + album_artist: None, + album: Some("The Dark Side of the Moon".into()), + title: Some("Time".into()), + track_number: Some(3), + disc_number: Some(1), + year: Some(1973), + genre: Some("Progressive Rock".into()), + ext: "flac".into(), + } + } + + #[test] + fn test_render_default_format() { + let meta = test_meta(); + let result = render(DEFAULT_FORMAT, &meta); + assert_eq!(result, "Pink Floyd/The Dark Side of the Moon/03 - Time.flac"); + } + + #[test] + fn test_render_custom_format() { + let meta = test_meta(); + let result = render("{artist} - {album}/{disc_number}-{track_number} {title}.{ext}", &meta); + assert_eq!(result, "Pink Floyd - The Dark Side of the Moon/1-03 Time.flac"); + } + + #[test] + fn test_render_missing_metadata() { + let meta = TrackMetadata { + ext: "mp3".into(), + ..Default::default() + }; + let result = render(DEFAULT_FORMAT, &meta); + assert_eq!(result, "Unknown Artist/Unknown Album/00 - Unknown Title.mp3"); + } + + #[test] + fn test_render_album_artist_preferred() { + let meta = TrackMetadata { + artist: Some("feat. Someone".into()), + album_artist: Some("Main Artist".into()), + album: Some("Album".into()), + title: Some("Song".into()), + ext: "mp3".into(), + ..Default::default() + }; + let result = render("{artist}/{album}/{title}.{ext}", &meta); + assert_eq!(result, "Main Artist/Album/Song.mp3"); + } + + #[test] + fn test_render_sanitizes_invalid_chars() { + let meta = TrackMetadata { + artist: Some("AC/DC".into()), + album: Some("What's Next?".into()), + title: Some("T.N.T.".into()), + ext: "mp3".into(), + ..Default::default() + }; + let result = render(DEFAULT_FORMAT, &meta); + assert_eq!(result, "AC_DC/What's Next_/00 - T.N.T.mp3"); + } + + #[test] + fn test_render_year_and_genre() { + let meta = test_meta(); + let result = render("{year} - {genre}/{artist}/{title}.{ext}", &meta); + assert_eq!(result, "1973 - Progressive Rock/Pink Floyd/Time.flac"); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..14c16e9 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,244 @@ +use std::fs; +use std::io::Write; + +use chrono::Utc; +use sea_orm::ActiveValue::Set; +use shanty_db::{Database, queries}; +use shanty_org::{DEFAULT_FORMAT, OrgConfig, organize_from_db, organize_from_directory}; +use tempfile::TempDir; + +/// Create a minimal valid MP3 file with ID3v2 tags. +fn create_test_mp3(path: &std::path::Path, title: &str, artist: &str, album: &str, track: u32) { + use lofty::config::WriteOptions; + use lofty::tag::{Accessor, ItemKey, ItemValue, Tag, TagExt, TagItem, TagType}; + + // Write minimal valid MPEG frames + let frame_header: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00]; + let frame_size = 417; + let mut frame_data = vec![0u8; frame_size]; + frame_data[..4].copy_from_slice(&frame_header); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut file = fs::File::create(path).unwrap(); + for _ in 0..10 { + file.write_all(&frame_data).unwrap(); + } + drop(file); + + let mut tag = Tag::new(TagType::Id3v2); + tag.set_title(title.to_string()); + tag.set_artist(artist.to_string()); + tag.set_album(album.to_string()); + tag.set_track(track); + tag.insert(TagItem::new( + ItemKey::AlbumArtist, + ItemValue::Text(artist.to_string()), + )); + tag.save_to_path(path, WriteOptions::default()).unwrap(); +} + +async fn test_db() -> Database { + Database::new("sqlite::memory:") + .await + .expect("failed to create test database") +} + +#[tokio::test] +async fn test_organize_from_directory() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + create_test_mp3( + &source.path().join("song1.mp3"), + "Time", + "Pink Floyd", + "The Dark Side of the Moon", + 3, + ); + create_test_mp3( + &source.path().join("song2.mp3"), + "Money", + "Pink Floyd", + "The Dark Side of the Moon", + 6, + ); + + let config = OrgConfig { + target_dir: target.path().to_owned(), + format: DEFAULT_FORMAT.to_string(), + dry_run: false, + copy: false, + }; + + let stats = organize_from_directory(source.path(), &config) + .await + .unwrap(); + + assert_eq!(stats.files_found, 2); + assert_eq!(stats.files_organized, 2); + assert_eq!(stats.files_errored, 0); + + // Verify the organized structure + let time_path = target + .path() + .join("Pink Floyd/The Dark Side of the Moon/03 - Time.mp3"); + let money_path = target + .path() + .join("Pink Floyd/The Dark Side of the Moon/06 - Money.mp3"); + assert!(time_path.exists(), "Time should exist at {}", time_path.display()); + assert!(money_path.exists(), "Money should exist at {}", money_path.display()); + + // Source files should be gone (moved, not copied) + assert!(!source.path().join("song1.mp3").exists()); + assert!(!source.path().join("song2.mp3").exists()); +} + +#[tokio::test] +async fn test_organize_copy_mode() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + create_test_mp3( + &source.path().join("song.mp3"), + "Time", + "Pink Floyd", + "DSOTM", + 1, + ); + + let config = OrgConfig { + target_dir: target.path().to_owned(), + format: DEFAULT_FORMAT.to_string(), + dry_run: false, + copy: true, + }; + + let stats = organize_from_directory(source.path(), &config) + .await + .unwrap(); + + assert_eq!(stats.files_organized, 1); + + // Source should still exist (copy mode) + assert!(source.path().join("song.mp3").exists()); + // Target should exist too + assert!(target.path().join("Pink Floyd/DSOTM/01 - Time.mp3").exists()); +} + +#[tokio::test] +async fn test_organize_dry_run() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + create_test_mp3( + &source.path().join("song.mp3"), + "Time", + "Pink Floyd", + "DSOTM", + 1, + ); + + let config = OrgConfig { + target_dir: target.path().to_owned(), + format: DEFAULT_FORMAT.to_string(), + dry_run: true, + copy: false, + }; + + let stats = organize_from_directory(source.path(), &config) + .await + .unwrap(); + + assert_eq!(stats.files_organized, 1); + + // Nothing should have actually moved + assert!(source.path().join("song.mp3").exists()); + // Target dir should be empty + let entries: Vec<_> = fs::read_dir(target.path()).unwrap().collect(); + assert!(entries.is_empty()); +} + +#[tokio::test] +async fn test_organize_from_db_updates_path() { + let db = test_db().await; + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + let source_path = source.path().join("song.mp3"); + create_test_mp3(&source_path, "Time", "Pink Floyd", "DSOTM", 3); + + // Insert track into DB + let now = Utc::now().naive_utc(); + let active = shanty_db::entities::track::ActiveModel { + file_path: Set(source_path.to_string_lossy().to_string()), + title: Set(Some("Time".into())), + artist: Set(Some("Pink Floyd".into())), + album: Set(Some("DSOTM".into())), + album_artist: Set(Some("Pink Floyd".into())), + track_number: Set(Some(3)), + file_size: Set(source_path.metadata().unwrap().len() as i64), + added_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + let track = queries::tracks::upsert(db.conn(), active).await.unwrap(); + + let config = OrgConfig { + target_dir: target.path().to_owned(), + format: DEFAULT_FORMAT.to_string(), + dry_run: false, + copy: false, + }; + + let stats = organize_from_db(db.conn(), &config).await.unwrap(); + assert_eq!(stats.files_organized, 1); + + // Verify DB path was updated + let updated = queries::tracks::get_by_id(db.conn(), track.id) + .await + .unwrap(); + let expected = target + .path() + .join("Pink Floyd/DSOTM/03 - Time.mp3") + .to_string_lossy() + .to_string(); + assert_eq!(updated.file_path, expected); +} + +#[tokio::test] +async fn test_organize_missing_metadata() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + // Create an MP3 with no tags (just valid audio frames) + let path = source.path().join("untagged.mp3"); + let frame_header: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00]; + let mut frame_data = vec![0u8; 417]; + frame_data[..4].copy_from_slice(&frame_header); + let mut file = fs::File::create(&path).unwrap(); + for _ in 0..10 { + file.write_all(&frame_data).unwrap(); + } + drop(file); + + let config = OrgConfig { + target_dir: target.path().to_owned(), + format: DEFAULT_FORMAT.to_string(), + dry_run: false, + copy: false, + }; + + let stats = organize_from_directory(source.path(), &config) + .await + .unwrap(); + + assert_eq!(stats.files_organized, 1); + + // Should use "Unknown" fallbacks + assert!(target + .path() + .join("Unknown Artist/Unknown Album/00 - Unknown Title.mp3") + .exists()); +}