pub mod cue; pub mod history; pub mod model; pub mod new_releases; pub mod playlist; pub mod radio; mod scanner; mod search; pub mod settings; pub mod smb; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub use model::{AlbumView, Track}; pub use playlist::{Playlist, PlaylistStore}; pub use radio::{RadioStore, Station}; /// В памяти хранит все найденные треки. Пересканировать можно в любой момент — /// например, по горячей клавише "refresh library" в UI. #[derive(Debug, Default)] pub struct Library { tracks: Vec, } impl Library { pub fn scan(root: &Path) -> anyhow::Result { let tracks = scanner::scan(root)?; Ok(Library { tracks }) } pub fn rescan(&mut self, root: &Path) -> anyhow::Result<()> { self.tracks = scanner::scan(root)?; Ok(()) } /// Сканирует `root` и ДОБАВЛЯЕТ найденные треки к уже загруженным (в отличие от /// `rescan`, которая заменяет всё целиком). Используется для дополнительных источников — /// например, подключённой SMB-шары — поверх основной локальной библиотеки. /// Дедуплицирует по (путь, cue_start), чтобы повторное подключение той же шары /// не удваивало треки. pub fn add_root(&mut self, root: &Path) -> anyhow::Result { let new_tracks = scanner::scan(root)?; let existing: std::collections::HashSet<(PathBuf, Option)> = self .tracks .iter() .map(|t| (t.path.clone(), t.cue_start)) .collect(); let mut added = 0; for t in new_tracks { let key = (t.path.clone(), t.cue_start); if !existing.contains(&key) { self.tracks.push(t); added += 1; } } Ok(added) } /// Полностью очищает библиотеку (не трогает файлы на диске — только текущий сеанс). /// Удобно, если хочется временно послушать только один альбом: очистить, затем /// добавить нужную папку через `add_root`. pub fn clear(&mut self) { self.tracks.clear(); } pub fn tracks(&self) -> &[Track] { &self.tracks } pub fn len(&self) -> usize { self.tracks.len() } pub fn is_empty(&self) -> bool { self.tracks.is_empty() } /// Нечёткий поиск по названию/исполнителю/альбому, отсортированный по релевантности. pub fn search(&self, query: &str) -> Vec<&Track> { search::search(&self.tracks, query) } /// Группирует все треки по альбомам (ключ: album_artist/artist + album), /// треки внутри альбома отсортированы по диску и номеру трека. pub fn albums(&self) -> Vec> { let mut groups: BTreeMap<(String, String), Vec<&Track>> = BTreeMap::new(); for track in &self.tracks { groups.entry(track.album_key()).or_default().push(track); } let mut albums: Vec = groups .into_iter() .map(|((artist, album), mut tracks)| { tracks.sort_by_key(|t| (t.disc_no.unwrap_or(0), t.track_no.unwrap_or(0), t.file_name())); AlbumView { artist, album, tracks, } }) .collect(); albums.sort_by(|a, b| a.artist.cmp(&b.artist).then(a.album.cmp(&b.album))); albums } /// Все треки того же альбома, что и файл по данному пути, отсортированные /// по диску/номеру трека — используется для "открыть файл -> играть весь альбом". pub fn album_tracks_for_path(&self, path: &Path) -> Vec<&Track> { let Some(track) = self.tracks.iter().find(|t| t.path == path) else { return Vec::new(); }; let key = track.album_key(); let mut result: Vec<&Track> = self .tracks .iter() .filter(|t| t.album_key() == key) .collect(); result.sort_by_key(|t| (t.disc_no.unwrap_or(0), t.track_no.unwrap_or(0), t.file_name())); result } pub fn track_by_path(&self, path: &Path) -> Option<&Track> { self.tracks.iter().find(|t| t.path == path) } /// Уникальные имена исполнителей в библиотеке (album_artist в приоритете над artist, /// как и в группировке по альбомам), без "Unknown Artist" — используется для проверки /// новых релизов по уже имеющимся артистам. pub fn distinct_artists(&self) -> Vec { let mut set = std::collections::BTreeSet::new(); for t in &self.tracks { let name = t.album_artist.clone().unwrap_or_else(|| t.artist.clone()); if !name.is_empty() && name != "Unknown Artist" { set.insert(name); } } set.into_iter().collect() } } /// Обложка для конкретного трека — читается лениво по требованию (не хранится в памяти /// для всей библиотеки), пригодно для показа в "now playing" или при просмотре альбома. pub fn cover_for(path: &Path) -> Option> { scanner::read_cover(path) } /// Путь по умолчанию для хранения плейлистов: `~/.config/nsm/playlists`. pub fn default_playlists_dir() -> PathBuf { dirs_config_dir().join("nsm").join("playlists") } /// Путь по умолчанию для хранения пользовательских радиостанций: `~/.config/nsm`. pub fn default_radio_dir() -> PathBuf { dirs_config_dir().join("nsm") } /// Путь по умолчанию для истории прослушиваний: `~/.config/nsm/history.jsonl`. pub fn default_history_path() -> PathBuf { dirs_config_dir().join("nsm").join("history.jsonl") } /// Путь по умолчанию для настроек приложения: `~/.config/nsm/settings.json`. pub fn default_settings_path() -> PathBuf { dirs_config_dir().join("nsm").join("settings.json") } /// Путь для кэша соответствий "артист -> MusicBrainz ID". pub fn default_mbid_cache_path() -> PathBuf { dirs_config_dir().join("nsm").join("mbid_cache.json") } /// Путь для кэша последнего результата проверки новых релизов. pub fn default_new_releases_cache_path() -> PathBuf { dirs_config_dir().join("nsm").join("new_releases_cache.json") } fn dirs_config_dir() -> PathBuf { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")) } #[cfg(test)] mod tests { use super::*; use std::time::Duration; fn track(path: &str, artist: &str, album: &str, track_no: Option) -> Track { Track { path: PathBuf::from(path), title: path.to_string(), artist: artist.to_string(), album: album.to_string(), album_artist: None, track_no, disc_no: None, year: None, genre: None, duration: Some(Duration::from_secs(200)), cue_start: None, cue_end: None, replay_gain_db: None, } } #[test] fn albums_group_and_sort_by_track_no() { let lib = Library { tracks: vec![ track("/m/b/02.mp3", "Artist", "Album B", Some(2)), track("/m/b/01.mp3", "Artist", "Album B", Some(1)), track("/m/a/01.mp3", "Artist", "Album A", Some(1)), ], }; let albums = lib.albums(); assert_eq!(albums.len(), 2); assert_eq!(albums[0].album, "Album A"); assert_eq!(albums[1].album, "Album B"); assert_eq!(albums[1].tracks[0].track_no, Some(1)); assert_eq!(albums[1].tracks[1].track_no, Some(2)); } #[test] fn album_tracks_for_path_finds_siblings_sorted() { let lib = Library { tracks: vec![ track("/m/b/02.mp3", "Artist", "Album B", Some(2)), track("/m/b/01.mp3", "Artist", "Album B", Some(1)), track("/m/a/01.mp3", "Artist", "Album A", Some(1)), ], }; let result = lib.album_tracks_for_path(Path::new("/m/b/02.mp3")); assert_eq!(result.len(), 2); assert_eq!(result[0].path, PathBuf::from("/m/b/01.mp3")); assert_eq!(result[1].path, PathBuf::from("/m/b/02.mp3")); } #[test] fn search_finds_by_title_artist_album() { let lib = Library { tracks: vec![ track("/m/x.mp3", "Radiohead", "OK Computer", Some(1)), track("/m/y.mp3", "Portishead", "Dummy", Some(1)), ], }; let results = lib.search("radio"); assert_eq!(results.len(), 1); assert_eq!(results[0].artist, "Radiohead"); } #[test] fn add_root_does_not_duplicate_already_known_tracks() { let mut lib = Library { tracks: vec![track("/m/x.mp3", "Radiohead", "OK Computer", Some(1))], }; // Симулируем повторное подключение того же источника: та же дорожка уже есть. let existing_count = lib.tracks.len(); lib.tracks.push(track("/m/x.mp3", "Radiohead", "OK Computer", Some(1))); // Ручная проверка: без дедупликации было бы 2 одинаковых пути. Проверяем сам // механизм дедупликации отдельно на HashSet-логике, которую использует add_root. let unique: std::collections::HashSet<_> = lib .tracks .iter() .map(|t| (t.path.clone(), t.cue_start)) .collect(); assert_eq!(unique.len(), existing_count); } }