192 lines
5.7 KiB
Rust
192 lines
5.7 KiB
Rust
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);
|
|
}
|