Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
26
Cargo.toml
Normal file
26
Cargo.toml
Normal 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
34
readme.md
Normal 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
27
src/error.rs
Normal 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
14
src/lib.rs
Normal 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
103
src/main.rs
Normal 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
82
src/metadata.rs
Normal 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
355
src/organizer.rs
Normal 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(¤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<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
80
src/sanitize.rs
Normal 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
141
src/template.rs
Normal 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
244
tests/integration.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user