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