diff --git a/src/library/mod.rs b/src/library/mod.rs
@@ -0,0 +1,248 @@
+pub mod scanner;
+pub mod track;
+pub mod watcher;
+
+use std::collections::BTreeSet;
+use std::path::Path;
+use track::Track;
+
+#[derive(Debug)]
+pub struct Library {
+ pub tracks: Vec<Track>,
+}
+
+impl Library {
+ pub fn new() -> Self {
+ Self {
+ tracks: Vec::new(),
+ }
+ }
+
+ pub fn scan(path: &Path) -> Self {
+ let tracks = scanner::scan_directory(path);
+ Self { tracks }
+ }
+
+ pub fn get_artists(&self) -> Vec<String> {
+ let mut set = BTreeSet::new();
+ let mut has_unknown = false;
+ for t in &self.tracks {
+ if t.artist.is_empty() {
+ has_unknown = true;
+ } else {
+ set.insert(t.artist.clone());
+ }
+ }
+ let mut result: Vec<String> = set.into_iter().collect();
+ if has_unknown {
+ result.push("Unknown Artist".to_string());
+ }
+ result
+ }
+
+ #[allow(dead_code)]
+ pub fn get_album_artists(&self) -> Vec<String> {
+ let mut set = BTreeSet::new();
+ for t in &self.tracks {
+ if !t.album_artist.is_empty() {
+ set.insert(t.album_artist.clone());
+ }
+ }
+ set.into_iter().collect()
+ }
+
+ pub fn get_genres(&self) -> Vec<String> {
+ let mut set = BTreeSet::new();
+ for t in &self.tracks {
+ if !t.genre.is_empty() {
+ set.insert(t.genre.clone());
+ }
+ }
+ set.into_iter().collect()
+ }
+
+ pub fn get_albums(&self) -> Vec<(String, String)> {
+ let mut set = BTreeSet::new();
+ for t in &self.tracks {
+ if !t.album.is_empty() {
+ let artist = if t.album_artist.is_empty() {
+ t.artist.clone()
+ } else {
+ t.album_artist.clone()
+ };
+ set.insert((t.album.clone(), artist));
+ }
+ }
+ set.into_iter().collect()
+ }
+
+ pub fn get_tracks_by_artist(&self, artist: &str) -> Vec<usize> {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| {
+ if artist == "Unknown Artist" {
+ t.artist.is_empty()
+ } else {
+ t.artist == artist
+ }
+ })
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ #[allow(dead_code)]
+ pub fn get_tracks_by_album_artist(&self, album_artist: &str) -> Vec<usize> {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| t.album_artist == album_artist)
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ pub fn get_tracks_by_album(&self, album: &str) -> Vec<usize> {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| t.album == album)
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ pub fn get_tracks_by_genre(&self, genre: &str) -> Vec<usize> {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| t.genre == genre)
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ #[allow(dead_code)]
+ pub fn get_albums_by_album_artist(&self, album_artist: &str) -> Vec<String> {
+ let mut set = BTreeSet::new();
+ for t in &self.tracks {
+ if t.album_artist == album_artist && !t.album.is_empty() {
+ set.insert(t.album.clone());
+ }
+ }
+ set.into_iter().collect()
+ }
+
+ pub fn get_formats(&self) -> Vec<String> {
+ let mut set = BTreeSet::new();
+ for t in &self.tracks {
+ let ext = t.path.extension()
+ .map(|e| e.to_string_lossy().to_lowercase())
+ .unwrap_or_default();
+ if !ext.is_empty() {
+ set.insert(ext);
+ }
+ }
+ set.into_iter().collect()
+ }
+
+ pub fn get_tracks_by_format(&self, format: &str) -> Vec<usize> {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| {
+ t.path.extension()
+ .map(|e| e.to_string_lossy().to_lowercase() == format)
+ .unwrap_or(false)
+ })
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ pub fn get_directory_entries(&self, dir: &Path) -> (Vec<String>, Vec<usize>) {
+ let mut subdirs = BTreeSet::new();
+ let mut tracks = Vec::new();
+
+ for (i, t) in self.tracks.iter().enumerate() {
+ if let Some(parent) = t.path.parent() {
+ if parent == dir {
+ tracks.push(i);
+ } else if let Ok(rel) = parent.strip_prefix(dir) {
+ if let Some(first) = rel.components().next() {
+ subdirs.insert(first.as_os_str().to_string_lossy().to_string());
+ }
+ }
+ }
+ }
+
+ (subdirs.into_iter().collect(), tracks)
+ }
+
+ pub fn path_to_index(&self, path: &Path) -> Option<usize> {
+ self.tracks.iter().position(|t| t.path == path)
+ }
+
+ pub fn search(&self, query: &str) -> Vec<usize> {
+ if query.is_empty() {
+ return Vec::new();
+ }
+
+ // Extension filter: *.flac, *.mp3, etc.
+ if let Some(ext) = query.strip_prefix("*.") {
+ let ext = ext.to_lowercase();
+ return self.tracks.iter().enumerate()
+ .filter(|(_, t)| {
+ t.path.extension()
+ .map(|e| e.to_string_lossy().to_lowercase() == ext)
+ .unwrap_or(false)
+ })
+ .map(|(i, _)| i)
+ .collect();
+ }
+
+ // Field-specific filter: artist:, album:, genre:, title:
+ if let Some((prefix, value)) = query.split_once(':') {
+ let field = prefix.trim().to_lowercase();
+ let v = value.trim().to_lowercase();
+ if !v.is_empty() {
+ match field.as_str() {
+ "artist" => {
+ return self.tracks.iter().enumerate()
+ .filter(|(_, t)| t.artist.to_lowercase().contains(&v))
+ .map(|(i, _)| i).collect();
+ }
+ "album" => {
+ return self.tracks.iter().enumerate()
+ .filter(|(_, t)| t.album.to_lowercase().contains(&v))
+ .map(|(i, _)| i).collect();
+ }
+ "genre" => {
+ return self.tracks.iter().enumerate()
+ .filter(|(_, t)| t.genre.to_lowercase().contains(&v))
+ .map(|(i, _)| i).collect();
+ }
+ "title" => {
+ return self.tracks.iter().enumerate()
+ .filter(|(_, t)| t.title.to_lowercase().contains(&v))
+ .map(|(i, _)| i).collect();
+ }
+ _ => {} // unknown prefix, fall through to general search
+ }
+ }
+ }
+
+ // General search: title, artist, album, genre, filename
+ let q = query.to_lowercase();
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| {
+ t.title.to_lowercase().contains(&q)
+ || t.artist.to_lowercase().contains(&q)
+ || t.album.to_lowercase().contains(&q)
+ || t.genre.to_lowercase().contains(&q)
+ || t.path.file_name()
+ .map(|f| f.to_string_lossy().to_lowercase().contains(&q))
+ .unwrap_or(false)
+ })
+ .map(|(i, _)| i)
+ .collect()
+ }
+}
diff --git a/src/library/scanner.rs b/src/library/scanner.rs
@@ -0,0 +1,93 @@
+use rodio::Decoder;
+use std::fs::File;
+use std::io::BufReader;
+use std::panic;
+use std::path::Path;
+use symphonia::core::formats::FormatOptions;
+use symphonia::core::io::MediaSourceStream;
+use symphonia::core::meta::MetadataOptions;
+use symphonia::core::probe::Hint;
+use walkdir::WalkDir;
+
+use super::track::Track;
+
+const AUDIO_EXTENSIONS: &[&str] = &["flac", "mp3", "m4a", "ogg", "wav", "opus", "aac", "wma"];
+
+/// Check if we can decode this file: try rodio first, then symphonia direct probe
+fn is_decodable(path: &Path) -> bool {
+ let path = path.to_path_buf();
+ let result = panic::catch_unwind(move || {
+ // Try rodio auto-detect
+ if let Ok(file) = File::open(&path) {
+ if Decoder::new(BufReader::new(file)).is_ok() {
+ return true;
+ }
+ }
+
+ // Try symphonia direct probe (handles M4A/ALAC/MP4 that rodio can't)
+ if let Ok(file) = File::open(&path) {
+ let mss = MediaSourceStream::new(Box::new(file), Default::default());
+ let mut hint = Hint::new();
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
+ hint.with_extension(ext);
+ }
+ if symphonia::default::get_probe()
+ .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
+ .is_ok()
+ {
+ return true;
+ }
+ }
+
+ false
+ });
+ result.unwrap_or(false)
+}
+
+pub fn scan_directory(path: &Path) -> Vec<Track> {
+ let mut tracks = Vec::new();
+
+ for entry in WalkDir::new(path)
+ .follow_links(true)
+ .into_iter()
+ .filter_map(|e| e.ok())
+ {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+
+ // Skip macOS resource fork files
+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
+ if name.starts_with("._") {
+ continue;
+ }
+ }
+
+ let ext = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .map(|e| e.to_lowercase());
+
+ if let Some(ext) = ext {
+ if AUDIO_EXTENSIONS.contains(&ext.as_str()) {
+ if !is_decodable(path) {
+ continue;
+ }
+ if let Some(track) = Track::from_path(path) {
+ tracks.push(track);
+ }
+ }
+ }
+ }
+
+ tracks.sort_by(|a, b| {
+ a.album_artist
+ .cmp(&b.album_artist)
+ .then(a.album.cmp(&b.album))
+ .then(a.track_number.cmp(&b.track_number))
+ .then(a.title.cmp(&b.title))
+ });
+
+ tracks
+}
diff --git a/src/library/track.rs b/src/library/track.rs
@@ -0,0 +1,96 @@
+use lofty::file::AudioFile;
+use lofty::prelude::*;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+#[derive(Debug, Clone)]
+pub struct Track {
+ pub path: PathBuf,
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub album_artist: String,
+ pub genre: String,
+ pub track_number: Option<u32>,
+ pub duration: Duration,
+ pub bitrate: Option<u32>,
+ #[allow(dead_code)]
+ pub lyrics: Option<String>,
+}
+
+impl Track {
+ pub fn from_path(path: &Path) -> Option<Self> {
+ let tagged_file = lofty::read_from_path(path).ok()?;
+
+ let tag = tagged_file
+ .primary_tag()
+ .or_else(|| tagged_file.first_tag());
+
+ let properties = tagged_file.properties();
+ let duration = properties.duration();
+ let bitrate = properties.audio_bitrate();
+
+ let (title, artist, album, album_artist, genre, track_number, lyrics) =
+ if let Some(tag) = tag {
+ let title_str: String = tag.title().map(|s| s.to_string()).unwrap_or_default();
+ let artist_str: String = tag.artist().map(|s| s.to_string()).unwrap_or_default();
+ let album_str: String = tag.album().map(|s| s.to_string()).unwrap_or_default();
+ let aa_str: String = tag
+ .get_string(&ItemKey::AlbumArtist)
+ .map(|s| s.to_string())
+ .unwrap_or_default();
+ let genre_str: String = tag.genre().map(|s| s.to_string()).unwrap_or_default();
+ let track_num = tag.track();
+ let lyrics_str: Option<String> = tag
+ .get_string(&ItemKey::Lyrics)
+ .map(|s| s.to_string());
+ (title_str, artist_str, album_str, aa_str, genre_str, track_num, lyrics_str)
+ } else {
+ (String::new(), String::new(), String::new(), String::new(), String::new(), None, None)
+ };
+
+ let title = if title.is_empty() {
+ path.file_stem()
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_else(|| "Unknown".to_string())
+ } else {
+ title
+ };
+
+ Some(Self {
+ path: path.to_path_buf(),
+ title,
+ artist,
+ album,
+ album_artist,
+ genre,
+ track_number,
+ duration,
+ bitrate,
+ lyrics,
+ })
+ }
+
+ pub fn display_artist(&self) -> &str {
+ if self.artist.is_empty() {
+ "Unknown Artist"
+ } else {
+ &self.artist
+ }
+ }
+
+ pub fn display_album(&self) -> &str {
+ if self.album.is_empty() {
+ "Unknown Album"
+ } else {
+ &self.album
+ }
+ }
+
+ pub fn format_duration(&self) -> String {
+ let secs = self.duration.as_secs();
+ let mins = secs / 60;
+ let secs = secs % 60;
+ format!("{}:{:02}", mins, secs)
+ }
+}
diff --git a/src/library/watcher.rs b/src/library/watcher.rs
@@ -0,0 +1,76 @@
+use std::path::Path;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use crossbeam_channel::Sender;
+use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
+
+use crate::event::Event;
+use crate::library::Library;
+
+pub fn spawn_watcher(music_dir: &Path, event_tx: Sender<Event>) -> Option<RecommendedWatcher> {
+ let (notify_tx, notify_rx) = crossbeam_channel::unbounded();
+ let dir = music_dir.to_path_buf();
+
+ let mut watcher = RecommendedWatcher::new(
+ move |res: Result<notify::Event, notify::Error>| {
+ if let Ok(ev) = res {
+ let dominated = matches!(
+ ev.kind,
+ EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(_)
+ );
+ if dominated {
+ let _ = notify_tx.send(());
+ }
+ }
+ },
+ notify::Config::default(),
+ ).ok()?;
+
+ watcher.watch(music_dir, RecursiveMode::Recursive).ok()?;
+
+ let scanning = Arc::new(AtomicBool::new(false));
+
+ // Debounce thread
+ let scanning_clone = scanning.clone();
+ std::thread::spawn(move || {
+ let debounce = Duration::from_secs(2);
+ let mut last_event = Instant::now();
+
+ loop {
+ match notify_rx.recv_timeout(Duration::from_millis(500)) {
+ Ok(()) => {
+ last_event = Instant::now();
+ }
+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
+ if last_event.elapsed() >= debounce
+ && !scanning_clone.load(Ordering::Relaxed)
+ {
+ // Check if there were any events recently
+ // Drain any pending events
+ let mut had_events = false;
+ while notify_rx.try_recv().is_ok() {
+ had_events = true;
+ }
+ if had_events {
+ last_event = Instant::now();
+ continue;
+ }
+
+ // Only rescan if we actually saw events since last scan
+ if last_event.elapsed() < debounce + Duration::from_millis(600) {
+ scanning_clone.store(true, Ordering::Relaxed);
+ let lib = Library::scan(&dir);
+ let _ = event_tx.send(Event::LibraryReady(lib));
+ scanning_clone.store(false, Ordering::Relaxed);
+ }
+ }
+ }
+ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+ });
+
+ Some(watcher)
+}
|