Initial commit

This commit is contained in:
Connor Johnstone
2026-03-17 18:22:20 -04:00
commit 3159ee51ad
11 changed files with 1110 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
target/
.env
*.db
*.db-journal

26
Cargo.toml Normal file
View File

@@ -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"

34
readme.md Normal file
View File

@@ -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`

27
src/error.rs Normal file
View File

@@ -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<lofty::error::LoftyError> for OrgError {
fn from(e: lofty::error::LoftyError) -> Self {
OrgError::Metadata(e.to_string())
}
}
pub type OrgResult<T> = Result<T, OrgError>;

14
src/lib.rs Normal file
View File

@@ -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;

103
src/main.rs Normal file
View File

@@ -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<PathBuf>,
/// 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://<XDG_DATA_HOME>/shanty/shanty.db?mode=rwc
#[arg(long, env = "SHANTY_DATABASE_URL")]
database: Option<String>,
/// 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 <dir>");
}
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(())
}

82
src/metadata.rs Normal file
View File

@@ -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<String>,
pub album_artist: Option<String>,
pub album: Option<String>,
pub title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub year: Option<i32>,
pub genre: Option<String>,
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<Self> {
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)
}
}

355
src/organizer.rs Normal file
View File

@@ -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(&current) {
Ok(mut entries) => {
if entries.next().is_none() {
// Directory is empty
if let Err(e) = std::fs::remove_dir(&current) {
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<OrgStats> {
let mut stats = OrgStats::default();
let mut source_dirs: HashSet<PathBuf> = 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<OrgStats> {
let mut stats = OrgStats::default();
let source_root = source_dir
.canonicalize()
.unwrap_or_else(|_| source_dir.to_owned());
// Collect music files
let files: Vec<PathBuf> = 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<PathBuf> = 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");
}
}
}
}
}

80
src/sanitize.rs Normal file
View File

@@ -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);
}
}

141
src/template.rs Normal file
View File

@@ -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");
}
}

244
tests/integration.rs Normal file
View File

@@ -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());
}