ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit e7fc72c56f69f3deb70e1e6248a910bfaae83f17
parent c60eaede041dea6f87d12021f57e619b18c1fb00
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:31:13 +0900

Add music library scanning, metadata, and filesystem watching

Recursive scanner over the music directory, track model with tags read
via lofty, embedded/sidecar cover art lookup, and a notify-based watcher
that reports library changes back to the event loop.

Diffstat:
Asrc/library/mod.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/library/scanner.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/library/track.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/library/watcher.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 513 insertion(+), 0 deletion(-)

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)
+}