use crate::db::{self, Track};
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::prelude::ItemKey;
use lofty::probe::Probe;
use rusqlite::Connection;
use std::path::Path;
use walkdir::WalkDir;
pub const AUDIO_EXTS: &[&str] = &[
"flac", "mp3", "m4a", "mp4", "aac", "alac", "ogg", "oga", "opus", "wav", "wave", "aif", "aiff",
"aifc", "caf", "mka", "webm", "mp1", "mp2", "mpa",
];
pub fn is_audio(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
let e = e.to_ascii_lowercase();
AUDIO_EXTS.contains(&e.as_str())
})
.unwrap_or(false)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ScanStats {
pub seen: usize,
pub added: usize,
pub skipped: usize,
pub failed: usize,
}
pub fn read_track(path: &Path) -> Option<Track> {
let meta = std::fs::metadata(path).ok()?;
let (mtime, size) = stat(&meta);
let mut t = Track {
path: path.to_string_lossy().into_owned(),
mtime,
size,
..Default::default()
};
let Some(tagged) = Probe::open(path).ok().and_then(|p| p.read().ok()) else {
let mut stream = crate::audio::decode::AudioStream::open(path).ok()?;
if stream.spec().rate == 0 {
stream.next_chunk().ok()?;
}
let spec = stream.spec();
t.sample_rate = Some(spec.rate).filter(|r| *r != 0);
t.channels = Some(spec.channels).filter(|c| *c != 0);
t.duration_ms = stream.duration().map(|d| d.as_millis() as i64);
return Some(t);
};
let props = tagged.properties();
t.duration_ms = Some(props.duration().as_millis() as i64);
t.sample_rate = props.sample_rate();
t.channels = props.channels().map(|c| c as u16);
t.bit_depth = props.bit_depth();
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
let get = |k: ItemKey| {
tag.get_string(k)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
t.title = get(ItemKey::TrackTitle);
t.artist = get(ItemKey::TrackArtist);
t.album = get(ItemKey::AlbumTitle);
t.album_artist = get(ItemKey::AlbumArtist);
t.genre = get(ItemKey::Genre);
t.track_no = get(ItemKey::TrackNumber).and_then(|s| parse_leading_num(&s));
t.disc_no = get(ItemKey::DiscNumber).and_then(|s| parse_leading_num(&s));
t.year = get(ItemKey::RecordingDate)
.or_else(|| get(ItemKey::Year))
.and_then(|s| s.get(..4).and_then(|y| y.parse().ok()));
}
Some(t)
}
fn stat(meta: &std::fs::Metadata) -> (i64, i64) {
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
(mtime, meta.len() as i64)
}
fn parse_leading_num(s: &str) -> Option<u32> {
let digits: String = s
.trim()
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.parse().ok()
}
pub const SCAN_BATCH: usize = 500;
pub fn scan_dir<F>(conn: &mut Connection, root: &Path, mut progress: F) -> db::Result<ScanStats>
where
F: FnMut(&ScanStats, &Path),
{
let mut stats = ScanStats::default();
let Ok(root) = root.canonicalize() else {
return Ok(stats);
};
let mut files = Vec::new();
for entry in WalkDir::new(root).follow_links(false) {
match entry {
Ok(e) if e.file_type().is_file() && is_audio(e.path()) => files.push(e),
Ok(_) => {}
Err(_) => stats.failed += 1,
}
}
for batch in files.chunks(SCAN_BATCH) {
let tx = conn.transaction()?;
for entry in batch {
let path = entry.path();
stats.seen += 1;
let Some(path_str) = path.to_str() else {
stats.failed += 1;
progress(&stats, path);
continue;
};
let disk = std::fs::metadata(path).ok().map(|m| stat(&m));
if let (Some(d), Ok(Some(known))) = (disk, db::stat_of(&tx, path_str)) {
if d == known {
stats.skipped += 1;
progress(&stats, path);
continue;
}
}
match read_track(path) {
Some(t) => {
db::upsert(&tx, &t)?;
stats.added += 1;
}
None => stats.failed += 1,
}
progress(&stats, path);
}
tx.commit()?;
}
Ok(stats)
}