use anyhow::{Context, Result};
use lofty::prelude::{Accessor, TaggedFileExt};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
};
pub type TrackId = u64;
pub type PlaylistId = u64;
pub const ALL_TRACKS_ID: PlaylistId = 0;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
pub id: TrackId,
pub name: String,
pub path: PathBuf,
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub album: Option<String>,
#[serde(default)]
pub year: Option<u32>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default)]
pub lyrics: Option<String>,
}
impl Track {
pub fn display(&self) -> String {
match self.artist.as_deref().filter(|s| !s.is_empty()) {
Some(artist) => format!("{artist} — {}", self.name),
None => self.name.clone(),
}
}
}
struct FileTags {
title: Option<String>,
artist: Option<String>,
album: Option<String>,
year: Option<u32>,
genre: Option<String>,
}
fn track_from_file(id: TrackId, path: PathBuf) -> Track {
let tags = read_file_tags(&path);
let name = tags
.as_ref()
.and_then(|t| t.title.clone())
.or_else(|| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "Unknown".to_string());
Track {
id,
name,
artist: tags.as_ref().and_then(|t| t.artist.clone()),
album: tags.as_ref().and_then(|t| t.album.clone()),
year: tags.as_ref().and_then(|t| t.year),
genre: tags.as_ref().and_then(|t| t.genre.clone()),
lyrics: None,
path,
}
}
fn read_file_tags(path: &Path) -> Option<FileTags> {
use lofty::config::ParseOptions;
use lofty::probe::Probe;
let tagged = Probe::open(path)
.ok()?
.options(ParseOptions::new().read_cover_art(false))
.read()
.ok()?;
let tag = tagged.primary_tag().or_else(|| tagged.first_tag())?;
let nonempty = |s: String| if s.is_empty() { None } else { Some(s) };
Some(FileTags {
title: tag.title().map(|s| s.into_owned()).and_then(nonempty),
artist: tag.artist().map(|s| s.into_owned()).and_then(nonempty),
album: tag.album().map(|s| s.into_owned()).and_then(nonempty),
genre: tag.genre().map(|s| s.into_owned()).and_then(nonempty),
year: tag.date().map(|ts| u32::from(ts.year)),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
pub id: PlaylistId,
pub name: String,
pub tracks: Vec<TrackId>,
}
impl Playlist {
pub fn new(id: PlaylistId, name: impl Into<String>) -> Self {
Self {
id,
name: name.into(),
tracks: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Library {
pub tracks: Vec<Track>,
pub playlists: Vec<Playlist>,
next_track_id: TrackId,
next_playlist_id: PlaylistId,
}
impl Default for Library {
fn default() -> Self {
Self {
tracks: Vec::new(),
playlists: vec![Playlist::new(ALL_TRACKS_ID, "All Tracks")],
next_track_id: 1,
next_playlist_id: 1, }
}
}
impl Library {
pub fn data_dir() -> Result<PathBuf> {
xdg::BaseDirectories::with_prefix("audium")
.create_data_directory("")
.context("could not determine data directory")
}
pub fn music_dir() -> Result<PathBuf> {
Ok(Self::data_dir()?.join("music"))
}
fn index_path() -> Result<PathBuf> {
Ok(Self::data_dir()?.join("library.json"))
}
pub fn load() -> Result<Self> {
fs::create_dir_all(Self::data_dir()?)?;
fs::create_dir_all(Self::music_dir()?)?;
let index = Self::index_path()?;
if !index.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&index)
.with_context(|| format!("reading library index at {}", index.display()))?;
let mut lib: Self = serde_json::from_str(&raw)
.with_context(|| "parsing library.json — the file may be corrupted")?;
let mut changed = false;
let music_dir = Self::music_dir()?;
for t in &mut lib.tracks {
if !t.path.exists()
&& let Some(name) = t.path.file_name()
{
let candidate = music_dir.join(name);
if candidate.exists() {
t.path = candidate;
changed = true;
}
}
}
let mut removed = std::collections::HashSet::new();
lib.tracks.retain(|t| {
if t.path.exists() {
true
} else {
removed.insert(t.id);
changed = true;
false
}
});
if !removed.is_empty() {
for pl in &mut lib.playlists {
pl.tracks.retain(|id| !removed.contains(id));
}
}
if lib.playlists.is_empty() || lib.playlists[0].id != ALL_TRACKS_ID {
lib.playlists.retain(|pl| pl.id != ALL_TRACKS_ID);
let mut all = Playlist::new(ALL_TRACKS_ID, "All Tracks");
all.tracks = lib.tracks.iter().map(|t| t.id).collect();
lib.playlists.insert(0, all);
}
let known: std::collections::HashSet<PathBuf> = lib
.tracks
.iter()
.filter_map(|t| t.path.canonicalize().ok())
.collect();
if let Ok(entries) = fs::read_dir(&music_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Ok(canon) = path.canonicalize() else {
continue;
};
if known.contains(&canon) || crate::player::validate_decodable(&path).is_err() {
continue;
}
let id = lib.next_track_id;
lib.next_track_id += 1;
lib.playlists[0].tracks.push(id);
lib.tracks.push(track_from_file(id, path));
changed = true;
}
}
if changed {
lib.save()?;
}
Ok(lib)
}
pub fn save(&self) -> Result<()> {
let index = Self::index_path()?;
let raw = serde_json::to_string_pretty(self)?;
fs::write(&index, raw)
.with_context(|| format!("writing library index to {}", index.display()))?;
Ok(())
}
pub fn add_file(&mut self, source: &Path) -> Result<(Track, bool)> {
crate::player::validate_decodable(source)
.with_context(|| format!("cannot import \"{}\"", source.display()))?;
let music_dir = Self::music_dir()?;
let filename = source.file_name().context("source path has no filename")?;
let dest = music_dir.join(filename);
if !dest.exists() {
fs::copy(source, &dest)
.with_context(|| format!("copying {} -> {}", source.display(), dest.display()))?;
}
let dest_canon = dest.canonicalize().ok();
if let Some(existing) = self
.tracks
.iter()
.find(|t| t.path.canonicalize().ok() == dest_canon)
{
return Ok((existing.clone(), false));
}
let id = self.next_track_id;
self.next_track_id += 1;
let track = track_from_file(id, dest);
self.tracks.push(track.clone());
if let Some(all) = self.playlists.iter_mut().find(|p| p.id == ALL_TRACKS_ID) {
all.tracks.push(id);
}
self.save()?;
Ok((track, true))
}
pub fn remove_track(&mut self, id: TrackId) -> Result<()> {
self.tracks.retain(|t| t.id != id);
for pl in &mut self.playlists {
pl.tracks.retain(|&tid| tid != id);
}
self.save()
}
pub fn rename_track(&mut self, id: TrackId, new_name: impl Into<String>) -> Result<()> {
if let Some(t) = self.tracks.iter_mut().find(|t| t.id == id) {
t.name = new_name.into();
}
self.save()
}
pub fn update_track_metadata(
&mut self,
id: TrackId,
name: String,
artist: Option<String>,
album: Option<String>,
year: Option<u32>,
genre: Option<String>,
) -> Result<()> {
if let Some(t) = self.tracks.iter_mut().find(|t| t.id == id) {
t.name = name;
t.artist = artist;
t.album = album;
t.year = year;
t.genre = genre;
self.save()
} else {
Ok(())
}
}
pub fn set_track_lyrics(&mut self, id: TrackId, lyrics: Option<String>) -> Result<()> {
if let Some(t) = self.tracks.iter_mut().find(|t| t.id == id) {
t.lyrics = lyrics;
self.save()
} else {
Ok(())
}
}
pub fn track(&self, id: TrackId) -> Option<&Track> {
self.tracks.iter().find(|t| t.id == id)
}
pub fn create_playlist(&mut self, name: impl Into<String>) -> Result<PlaylistId> {
let id = self.next_playlist_id;
self.next_playlist_id += 1;
self.playlists.push(Playlist::new(id, name));
self.save()?;
Ok(id)
}
pub fn delete_playlist(&mut self, id: PlaylistId) -> Result<()> {
if id == ALL_TRACKS_ID {
return Ok(());
}
self.playlists.retain(|p| p.id != id);
self.save()
}
pub fn rename_playlist(&mut self, id: PlaylistId, name: impl Into<String>) -> Result<()> {
if id == ALL_TRACKS_ID {
return Ok(());
}
if let Some(pl) = self.playlists.iter_mut().find(|p| p.id == id) {
pl.name = name.into();
}
self.save()
}
pub fn playlist_add_track(&mut self, playlist_id: PlaylistId, track_id: TrackId) -> Result<()> {
if let Some(pl) = self.playlists.iter_mut().find(|p| p.id == playlist_id)
&& !pl.tracks.contains(&track_id)
{
pl.tracks.push(track_id);
}
self.save()
}
pub fn playlist(&self, id: PlaylistId) -> Option<&Playlist> {
self.playlists.iter().find(|p| p.id == id)
}
pub fn playlist_tracks(&self, playlist_id: PlaylistId) -> Vec<&Track> {
self.playlist(playlist_id)
.map(|pl| pl.tracks.iter().filter_map(|id| self.track(*id)).collect())
.unwrap_or_default()
}
}