nm/crates/library/src/lib.rs
2026-07-13 09:24:20 +03:00

271 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Track>,
}
impl Library {
pub fn scan(root: &Path) -> anyhow::Result<Self> {
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<usize> {
let new_tracks = scanner::scan(root)?;
let existing: std::collections::HashSet<(PathBuf, Option<std::time::Duration>)> = 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<AlbumView<'_>> {
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<AlbumView> = 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<String> {
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<Vec<u8>> {
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<u32>) -> 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);
}
}