Compare commits
1 Commits
3159ee51ad
...
a2152cbf8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2152cbf8d |
@@ -7,7 +7,10 @@ use shanty_db::Database;
|
|||||||
use shanty_org::{DEFAULT_FORMAT, OrgConfig, organize_from_db, organize_from_directory};
|
use shanty_org::{DEFAULT_FORMAT, OrgConfig, organize_from_db, organize_from_directory};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "shanty-org", about = "Organize music files into a clean directory structure")]
|
#[command(
|
||||||
|
name = "shanty-org",
|
||||||
|
about = "Organize music files into a clean directory structure"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Source directory of music files (standalone mode, reads tags from files).
|
/// Source directory of music files (standalone mode, reads tags from files).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ impl TrackMetadata {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let tagged_file = Probe::open(path)?
|
let tagged_file = Probe::open(path)?.options(ParseOptions::default()).read()?;
|
||||||
.options(ParseOptions::default())
|
|
||||||
.read()?;
|
|
||||||
|
|
||||||
let tag = tagged_file
|
let tag = tagged_file
|
||||||
.primary_tag()
|
.primary_tag()
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ fn resolve_target(target: &Path) -> PathBuf {
|
|||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
let ext = target
|
let ext = target.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
.extension()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let parent = target.parent().unwrap_or(Path::new("."));
|
let parent = target.parent().unwrap_or(Path::new("."));
|
||||||
|
|
||||||
for i in 2..=999 {
|
for i in 2..=999 {
|
||||||
@@ -235,10 +232,7 @@ pub async fn organize_from_db(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Organize music files from a source directory (standalone, no database).
|
/// Organize music files from a source directory (standalone, no database).
|
||||||
pub async fn organize_from_directory(
|
pub async fn organize_from_directory(source_dir: &Path, config: &OrgConfig) -> OrgResult<OrgStats> {
|
||||||
source_dir: &Path,
|
|
||||||
config: &OrgConfig,
|
|
||||||
) -> OrgResult<OrgStats> {
|
|
||||||
let mut stats = OrgStats::default();
|
let mut stats = OrgStats::default();
|
||||||
let source_root = source_dir
|
let source_root = source_dir
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
@@ -344,12 +338,11 @@ fn cleanup_empty_dirs_recursive(root: &Path) {
|
|||||||
if dir == root {
|
if dir == root {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&dir) {
|
if let Ok(mut entries) = std::fs::read_dir(&dir)
|
||||||
if entries.next().is_none() {
|
&& entries.next().is_none()
|
||||||
if std::fs::remove_dir(&dir).is_ok() {
|
&& std::fs::remove_dir(&dir).is_ok()
|
||||||
|
{
|
||||||
tracing::debug!(path = %dir.display(), "removed empty directory");
|
tracing::debug!(path = %dir.display(), "removed empty directory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ pub fn sanitize_component(s: &str) -> String {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Trim leading/trailing dots and spaces (problematic on Windows and some Linux tools)
|
// Trim leading/trailing dots and spaces (problematic on Windows and some Linux tools)
|
||||||
result = result.trim_matches(|c: char| c == '.' || c == ' ').to_string();
|
result = result
|
||||||
|
.trim_matches(|c: char| c == '.' || c == ' ')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Collapse consecutive underscores
|
// Collapse consecutive underscores
|
||||||
while result.contains("__") {
|
while result.contains("__") {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ pub fn render(template: &str, meta: &TrackMetadata) -> String {
|
|||||||
|
|
||||||
// Split template by "/" so we can sanitize each path component individually.
|
// Split template by "/" so we can sanitize each path component individually.
|
||||||
// The extension is special — it's part of the filename, not sanitized separately.
|
// The extension is special — it's part of the filename, not sanitized separately.
|
||||||
let result = template
|
template
|
||||||
.replace("{artist}", &sanitize_component(artist))
|
.replace("{artist}", &sanitize_component(artist))
|
||||||
.replace("{album_artist}", &sanitize_component(album_artist))
|
.replace("{album_artist}", &sanitize_component(album_artist))
|
||||||
.replace("{album}", &sanitize_component(album))
|
.replace("{album}", &sanitize_component(album))
|
||||||
@@ -58,9 +58,7 @@ pub fn render(template: &str, meta: &TrackMetadata) -> String {
|
|||||||
.replace("{disc_number}", &disc_number)
|
.replace("{disc_number}", &disc_number)
|
||||||
.replace("{year}", &year)
|
.replace("{year}", &year)
|
||||||
.replace("{genre}", &sanitize_component(genre))
|
.replace("{genre}", &sanitize_component(genre))
|
||||||
.replace("{ext}", ext);
|
.replace("{ext}", ext)
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -85,14 +83,23 @@ mod tests {
|
|||||||
fn test_render_default_format() {
|
fn test_render_default_format() {
|
||||||
let meta = test_meta();
|
let meta = test_meta();
|
||||||
let result = render(DEFAULT_FORMAT, &meta);
|
let result = render(DEFAULT_FORMAT, &meta);
|
||||||
assert_eq!(result, "Pink Floyd/The Dark Side of the Moon/03 - Time.flac");
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Pink Floyd/The Dark Side of the Moon/03 - Time.flac"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_render_custom_format() {
|
fn test_render_custom_format() {
|
||||||
let meta = test_meta();
|
let meta = test_meta();
|
||||||
let result = render("{artist} - {album}/{disc_number}-{track_number} {title}.{ext}", &meta);
|
let result = render(
|
||||||
assert_eq!(result, "Pink Floyd - The Dark Side of the Moon/1-03 Time.flac");
|
"{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]
|
#[test]
|
||||||
@@ -102,7 +109,10 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let result = render(DEFAULT_FORMAT, &meta);
|
let result = render(DEFAULT_FORMAT, &meta);
|
||||||
assert_eq!(result, "Unknown Artist/Unknown Album/00 - Unknown Title.mp3");
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Unknown Artist/Unknown Album/00 - Unknown Title.mp3"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -87,8 +87,16 @@ async fn test_organize_from_directory() {
|
|||||||
let money_path = target
|
let money_path = target
|
||||||
.path()
|
.path()
|
||||||
.join("Pink Floyd/The Dark Side of the Moon/06 - Money.mp3");
|
.join("Pink Floyd/The Dark Side of the Moon/06 - Money.mp3");
|
||||||
assert!(time_path.exists(), "Time should exist at {}", time_path.display());
|
assert!(
|
||||||
assert!(money_path.exists(), "Money should exist at {}", money_path.display());
|
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)
|
// Source files should be gone (moved, not copied)
|
||||||
assert!(!source.path().join("song1.mp3").exists());
|
assert!(!source.path().join("song1.mp3").exists());
|
||||||
@@ -124,7 +132,12 @@ async fn test_organize_copy_mode() {
|
|||||||
// Source should still exist (copy mode)
|
// Source should still exist (copy mode)
|
||||||
assert!(source.path().join("song.mp3").exists());
|
assert!(source.path().join("song.mp3").exists());
|
||||||
// Target should exist too
|
// Target should exist too
|
||||||
assert!(target.path().join("Pink Floyd/DSOTM/01 - Time.mp3").exists());
|
assert!(
|
||||||
|
target
|
||||||
|
.path()
|
||||||
|
.join("Pink Floyd/DSOTM/01 - Time.mp3")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -237,8 +250,10 @@ async fn test_organize_missing_metadata() {
|
|||||||
assert_eq!(stats.files_organized, 1);
|
assert_eq!(stats.files_organized, 1);
|
||||||
|
|
||||||
// Should use "Unknown" fallbacks
|
// Should use "Unknown" fallbacks
|
||||||
assert!(target
|
assert!(
|
||||||
|
target
|
||||||
.path()
|
.path()
|
||||||
.join("Unknown Artist/Unknown Album/00 - Unknown Title.mp3")
|
.join("Unknown Artist/Unknown Album/00 - Unknown Title.mp3")
|
||||||
.exists());
|
.exists()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user