use std::fs; use std::io::Write; use lofty::tag::TagExt; use shanty_db::{Database, queries}; use shanty_index::{ScanConfig, run_scan}; use tempfile::TempDir; /// Create a minimal valid MP3 file with ID3v2 tags using lofty. fn create_test_mp3(dir: &std::path::Path, filename: &str, title: &str, artist: &str, album: &str) { use lofty::config::WriteOptions; use lofty::tag::{Accessor, ItemKey, Tag, TagType}; let path = dir.join(filename); // Write a minimal valid MPEG frame (silence, ~0.026s at 128kbps) // MPEG1 Layer 3, 128kbps, 44100Hz, stereo frame header + padding let frame_header: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00]; let frame_size = 417; // standard frame size for 128kbps/44100Hz let mut frame_data = vec![0u8; frame_size]; frame_data[..4].copy_from_slice(&frame_header); // Write a few frames so lofty recognizes it as valid audio let mut file = fs::File::create(&path).unwrap(); for _ in 0..10 { file.write_all(&frame_data).unwrap(); } drop(file); // Now write tags using lofty 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(1); tag.set_disk(1); tag.set_year(2024); tag.set_genre("Rock".to_string()); tag.insert(lofty::tag::TagItem::new( ItemKey::AlbumArtist, lofty::tag::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_scan_indexes_music_files() { let db = test_db().await; let dir = TempDir::new().unwrap(); create_test_mp3(dir.path(), "song1.mp3", "Time", "Pink Floyd", "DSOTM"); create_test_mp3(dir.path(), "song2.mp3", "Money", "Pink Floyd", "DSOTM"); let config = ScanConfig { root: dir.path().to_owned(), dry_run: false, concurrency: 2, }; let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_found, 2); assert_eq!(stats.files_indexed, 2); assert_eq!(stats.files_errored, 0); // Verify tracks in DB let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap(); assert_eq!(tracks.len(), 2); // Verify artist was created let artist = queries::artists::find_by_name(db.conn(), "Pink Floyd") .await .unwrap(); assert!(artist.is_some()); // Verify album was created and linked to artist let albums = queries::albums::get_by_artist(db.conn(), artist.unwrap().id) .await .unwrap(); assert_eq!(albums.len(), 1); assert_eq!(albums[0].name, "DSOTM"); } #[tokio::test] async fn test_incremental_scan_skips_unchanged() { let db = test_db().await; let dir = TempDir::new().unwrap(); create_test_mp3(dir.path(), "song.mp3", "Time", "Pink Floyd", "DSOTM"); let config = ScanConfig { root: dir.path().to_owned(), dry_run: false, concurrency: 1, }; // First scan let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_indexed, 1); assert_eq!(stats.files_skipped, 0); // Second scan — should skip since mtime unchanged let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_indexed, 0); assert_eq!(stats.files_skipped, 1); } #[tokio::test] async fn test_dry_run_does_not_write() { let db = test_db().await; let dir = TempDir::new().unwrap(); create_test_mp3(dir.path(), "song.mp3", "Time", "Pink Floyd", "DSOTM"); let config = ScanConfig { root: dir.path().to_owned(), dry_run: true, concurrency: 1, }; let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_found, 1); assert_eq!(stats.files_indexed, 1); // counted as indexed in dry-run // But DB should be empty let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap(); assert!(tracks.is_empty()); } #[tokio::test] async fn test_partial_metadata_still_indexed() { let db = test_db().await; let dir = TempDir::new().unwrap(); // Create a file with minimal valid audio but no tags 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); let path = dir.path().join("untagged.mp3"); let mut file = fs::File::create(&path).unwrap(); for _ in 0..10 { file.write_all(&frame_data).unwrap(); } drop(file); let config = ScanConfig { root: dir.path().to_owned(), dry_run: false, concurrency: 1, }; let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_found, 1); assert_eq!(stats.files_indexed, 1); // Track should exist but with NULL metadata let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap(); assert_eq!(tracks.len(), 1); assert!(tracks[0].title.is_none()); assert!(tracks[0].artist.is_none()); // But should still have file-level info assert!(tracks[0].file_size > 0); assert!(tracks[0].codec.is_some()); } #[tokio::test] async fn test_non_music_files_ignored() { let db = test_db().await; let dir = TempDir::new().unwrap(); fs::write(dir.path().join("readme.txt"), b"not music").unwrap(); fs::write(dir.path().join("cover.jpg"), b"not music").unwrap(); fs::write(dir.path().join("data.json"), b"{}").unwrap(); let config = ScanConfig { root: dir.path().to_owned(), dry_run: false, concurrency: 1, }; let stats = run_scan(db.conn(), &config).await.unwrap(); assert_eq!(stats.files_found, 0); }