use anyhow::{Context, Result};
use lofty::prelude::{Accessor, TaggedFileExt};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
};
pub type TrackId = u64;
pub type PlaylistId = u64;
const FORMAT_VERSION: u32 = 1;
const INDEX_FILE: &str = "audium.json";
#[derive(Debug, Clone)]
pub struct Track {
pub id: TrackId,
pub path: PathBuf,
pub name: String,
pub artist: Option<String>,
pub album: Option<String>,
pub lyrics: Option<String>,
pub duration_secs: Option<u64>,
}
impl Track {
const fn at(id: TrackId, path: PathBuf) -> Self {
Self {
id,
path,
name: String::new(),
artist: None,
album: None,
lyrics: None,
duration_secs: None,
}
}
fn reload_metadata(&mut self) {
let tags = read_file_tags(&self.path);
self.name = tags
.as_ref()
.and_then(|t| t.title.clone())
.or_else(|| {
self.path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "Unknown".to_string());
self.artist = tags.as_ref().and_then(|t| t.artist.clone());
self.album = tags.as_ref().and_then(|t| t.album.clone());
self.lyrics = tags.as_ref().and_then(|t| t.lyrics.clone());
self.duration_secs = tags.as_ref().and_then(|t| t.duration_secs);
}
pub fn filename(&self) -> String {
self.path
.file_name()
.map_or_else(|| self.path.to_string_lossy(), |n| n.to_string_lossy())
.into_owned()
}
pub fn display(&self) -> String {
self.artist
.as_deref()
.filter(|s| !s.is_empty())
.map_or_else(
|| self.name.clone(),
|artist| format!("{artist} - {}", self.name),
)
}
}
struct FileTags {
title: Option<String>,
artist: Option<String>,
album: Option<String>,
lyrics: Option<String>,
duration_secs: Option<u64>,
}
fn track_from_file(id: TrackId, path: PathBuf) -> Track {
let mut track = Track::at(id, path);
track.reload_metadata();
track
}
fn load_metadata(tracks: &mut [Track]) {
if tracks.is_empty() {
return;
}
let threads = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
let chunk = tracks.len().div_ceil(threads.max(1));
std::thread::scope(|s| {
for slice in tracks.chunks_mut(chunk) {
s.spawn(|| {
for t in slice {
t.reload_metadata();
}
});
}
});
}
fn neighbour(index: usize, down: bool, len: usize) -> Option<usize> {
if down {
index.checked_add(1).filter(|&i| i < len)
} else {
index.checked_sub(1)
}
}
fn read_file_tags(path: &Path) -> Option<FileTags> {
use lofty::config::ParseOptions;
use lofty::file::AudioFile;
use lofty::probe::Probe;
let tagged = Probe::open(path)
.ok()?
.options(ParseOptions::new().read_cover_art(false))
.read()
.ok()?;
let duration_secs = Some(tagged.properties().duration().as_secs()).filter(|&d| d > 0);
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
return Some(FileTags {
title: None,
artist: None,
album: None,
lyrics: None,
duration_secs,
});
};
let nonempty = |s: String| if s.is_empty() { None } else { Some(s) };
Some(FileTags {
title: tag
.title()
.map(std::borrow::Cow::into_owned)
.and_then(nonempty),
artist: tag
.artist()
.map(std::borrow::Cow::into_owned)
.and_then(nonempty),
album: tag
.album()
.map(std::borrow::Cow::into_owned)
.and_then(nonempty),
lyrics: tag
.get_string(lofty::prelude::ItemKey::Lyrics)
.or_else(|| tag.get_string(lofty::prelude::ItemKey::UnsyncLyrics))
.map(ToString::to_string)
.and_then(nonempty),
duration_secs,
})
}
fn write_file_tags(
path: &Path,
name: &str,
artist: Option<&str>,
album: Option<&str>,
lyrics: Option<&str>,
) -> Result<()> {
use lofty::config::{ParseOptions, WriteOptions};
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::prelude::ItemKey;
use lofty::probe::Probe;
use lofty::tag::Tag;
let file = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
let mut tagged = Probe::open(path)
.with_context(|| format!("opening {file}"))?
.options(ParseOptions::new().read_cover_art(false))
.read()
.with_context(|| format!("reading tags from {file}"))?;
if tagged.primary_tag_mut().is_none() {
let kind = tagged.primary_tag_type();
tagged.insert_tag(Tag::new(kind));
}
let Some(tag) = tagged.primary_tag_mut() else {
anyhow::bail!("{file} cannot hold tags");
};
tag.set_title(name.to_string());
set_or_clear(tag, ItemKey::TrackArtist, artist);
set_or_clear(tag, ItemKey::AlbumTitle, album);
let lyrics_key = if tag.tag_type() == lofty::tag::TagType::Id3v2 {
ItemKey::UnsyncLyrics
} else {
ItemKey::Lyrics
};
set_or_clear(tag, lyrics_key, lyrics);
let tmp = path.with_extension("audium-tag.tmp");
let write_via_tmp = || -> Result<()> {
fs::copy(path, &tmp).with_context(|| format!("copying {file} to write its tags"))?;
tagged
.save_to_path(&tmp, WriteOptions::default())
.with_context(|| format!("writing tags to {file}"))?;
fs::rename(&tmp, path).with_context(|| format!("replacing {file}"))?;
Ok(())
};
let result = write_via_tmp();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
fn set_or_clear(tag: &mut lofty::tag::Tag, key: lofty::prelude::ItemKey, value: Option<&str>) {
use lofty::tag::{ItemValue, TagItem};
match value.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => {
tag.insert(TagItem::new(key, ItemValue::Text(v.to_string())));
}
None => {
tag.remove_key(key);
}
}
}
#[derive(Debug, Clone)]
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, Default)]
pub struct Library {
pub tracks: Vec<Track>,
pub playlists: Vec<Playlist>,
next_track_id: TrackId,
next_playlist_id: PlaylistId,
by_id: FxHashMap<TrackId, usize>,
}
#[derive(Serialize, Deserialize)]
struct IndexFile {
version: u32,
tracks: Vec<String>,
playlists: Vec<PlaylistRepr>,
}
#[derive(Serialize, Deserialize)]
struct PlaylistRepr {
name: String,
tracks: Vec<String>,
}
impl Default for IndexFile {
fn default() -> Self {
Self {
version: FORMAT_VERSION,
tracks: Vec::new(),
playlists: Vec::new(),
}
}
}
#[derive(Deserialize)]
struct VersionPeek {
#[serde(default)]
version: u32,
}
impl Library {
pub fn data_dir() -> Result<PathBuf> {
Self::xdg()
.create_data_directory("")
.context("could not determine data directory")
}
fn xdg() -> xdg::BaseDirectories {
xdg::BaseDirectories::with_prefix("audium")
}
pub fn find_config_file(name: &str) -> Option<PathBuf> {
Self::xdg().find_config_file(name)
}
pub fn find_state_file(name: &str) -> Option<PathBuf> {
Self::xdg().find_state_file(name)
}
pub fn place_state_file(name: &str) -> Result<PathBuf> {
Self::xdg()
.place_state_file(name)
.context("could not determine state directory")
}
pub fn place_config_file(name: &str) -> Result<PathBuf> {
Self::xdg()
.place_config_file(name)
.context("could not determine config directory")
}
pub fn music_dir() -> Result<PathBuf> {
Ok(Self::data_dir()?.join("music"))
}
fn index_path() -> Result<PathBuf> {
Ok(Self::data_dir()?.join(INDEX_FILE))
}
pub fn load() -> Result<Self> {
fs::create_dir_all(Self::data_dir()?)?;
let music_dir = Self::music_dir()?;
fs::create_dir_all(&music_dir)?;
let index = Self::index_path()?;
let (file, reset) = Self::read_index(&index)?;
let mut changed = reset;
let mut present: FxHashMap<String, PathBuf> = FxHashMap::default();
if let Ok(entries) = fs::read_dir(&music_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& crate::filepicker::is_audio(&path)
&& let Some(name) = path.file_name()
{
present.insert(name.to_string_lossy().into_owned(), path);
}
}
}
let mut order: Vec<String> = Vec::with_capacity(present.len());
let mut seen: FxHashSet<String> = FxHashSet::default();
for name in file.tracks {
if present.contains_key(&name) && seen.insert(name.clone()) {
order.push(name);
} else {
changed = true;
}
}
let mut extra: Vec<String> = present
.keys()
.filter(|n| !seen.contains(*n))
.cloned()
.collect();
extra.sort_unstable();
changed |= !extra.is_empty();
order.append(&mut extra);
let mut by_name: FxHashMap<String, TrackId> = FxHashMap::default();
let mut tracks: Vec<Track> = Vec::with_capacity(order.len());
let mut next_track_id: TrackId = 1;
for name in order {
let path = present
.get(&name)
.cloned()
.unwrap_or_else(|| music_dir.join(&name));
let id = next_track_id;
next_track_id += 1;
by_name.insert(name.clone(), id);
tracks.push(Track::at(id, path));
}
load_metadata(&mut tracks);
let mut next_playlist_id: PlaylistId = 1;
let playlists: Vec<Playlist> = file
.playlists
.into_iter()
.map(|p| {
let mut seen = FxHashSet::default();
let resolved: Vec<TrackId> = p
.tracks
.iter()
.filter_map(|n| by_name.get(n).copied())
.filter(|&id| seen.insert(id))
.collect();
changed |= resolved.len() != p.tracks.len();
let id = next_playlist_id;
next_playlist_id += 1;
Playlist {
id,
name: p.name,
tracks: resolved,
}
})
.collect();
let mut lib = Self {
tracks,
playlists,
next_track_id,
next_playlist_id,
by_id: FxHashMap::default(),
};
lib.reindex();
if changed {
lib.save()?;
}
Ok(lib)
}
fn read_index(index: &Path) -> Result<(IndexFile, bool)> {
if !index.exists() {
return Ok((IndexFile::default(), false));
}
let raw = fs::read_to_string(index)
.with_context(|| format!("reading library index at {}", index.display()))?;
if let Ok(file) = serde_json::from_str::<IndexFile>(&raw)
&& file.version == FORMAT_VERSION
{
return Ok((file, false));
}
let version = serde_json::from_str::<VersionPeek>(&raw).map_or(0, |v| v.version);
let aside = index.with_extension(format!("v{version}.json"));
fs::rename(index, &aside)
.with_context(|| format!("setting aside an unreadable index at {}", aside.display()))?;
Ok((IndexFile::default(), true))
}
pub fn save(&self) -> Result<()> {
let file = IndexFile {
version: FORMAT_VERSION,
tracks: self.tracks.iter().map(Track::filename).collect(),
playlists: self
.playlists
.iter()
.map(|p| PlaylistRepr {
name: p.name.clone(),
tracks: p
.tracks
.iter()
.filter_map(|&id| self.track(id).map(Track::filename))
.collect(),
})
.collect(),
};
let index = Self::index_path()?;
let tmp = index.with_extension("json.tmp");
let raw = serde_json::to_string_pretty(&file)?;
fs::write(&tmp, &raw)
.with_context(|| format!("writing library index to {}", tmp.display()))?;
fs::rename(&tmp, &index)
.with_context(|| format!("replacing library index at {}", 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 default_dest = music_dir.join(filename);
if default_dest.exists() {
let dest_canon = default_dest.canonicalize().ok();
if let Some(existing) = self
.tracks
.iter()
.find(|t| t.path.canonicalize().ok() == dest_canon)
{
return Ok((existing.clone(), false));
}
}
let source_canon = source.canonicalize().ok();
if source_canon.is_some()
&& let Some(existing) = self
.tracks
.iter()
.find(|t| t.path.canonicalize().ok() == source_canon)
{
return Ok((existing.clone(), false));
}
let dest = if default_dest.exists() {
let stem = default_dest
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("track");
let ext = default_dest
.extension()
.and_then(|s| s.to_str())
.map(|e| format!(".{e}"))
.unwrap_or_default();
let mut i = 1u32;
loop {
let candidate = music_dir.join(format!("{stem}-{i}{ext}"));
if !candidate.exists() {
break candidate;
}
i += 1;
}
} else {
default_dest
};
fs::copy(source, &dest)
.with_context(|| format!("copying {} -> {}", source.display(), dest.display()))?;
let id = self.next_track_id;
self.next_track_id += 1;
let track = track_from_file(id, dest);
self.by_id.insert(id, self.tracks.len());
self.tracks.push(track.clone());
self.save()?;
Ok((track, true))
}
pub fn remove_track(&mut self, id: TrackId) -> Result<()> {
let Some(pos) = self.tracks.iter().position(|t| t.id == id) else {
return Ok(());
};
let path = self.tracks.remove(pos).path;
self.reindex();
for pl in &mut self.playlists {
pl.tracks.retain(|&tid| tid != id);
}
let _ = fs::remove_file(&path);
self.save()
}
pub fn update_track_metadata(
&mut self,
id: TrackId,
name: String,
artist: Option<String>,
album: Option<String>,
) -> Result<()> {
let Some(t) = self.tracks.iter_mut().find(|t| t.id == id) else {
return Ok(());
};
t.name = name;
t.artist = artist;
t.album = album;
let (path, name, artist, album, lyrics) = (
t.path.clone(),
t.name.clone(),
t.artist.clone(),
t.album.clone(),
t.lyrics.clone(),
);
self.save()?;
write_file_tags(
&path,
&name,
artist.as_deref(),
album.as_deref(),
lyrics.as_deref(),
)
}
pub fn set_track_lyrics(&mut self, id: TrackId, lyrics: Option<String>) -> Result<()> {
let Some(t) = self.tracks.iter_mut().find(|t| t.id == id) else {
return Ok(());
};
t.lyrics = lyrics;
let (path, name, artist, album, lyrics) = (
t.path.clone(),
t.name.clone(),
t.artist.clone(),
t.album.clone(),
t.lyrics.clone(),
);
self.save()?;
write_file_tags(
&path,
&name,
artist.as_deref(),
album.as_deref(),
lyrics.as_deref(),
)
}
pub fn track_by_filename(&self, filename: &str) -> Option<&Track> {
self.tracks.iter().find(|t| t.filename() == filename)
}
pub fn track(&self, id: TrackId) -> Option<&Track> {
self.by_id.get(&id).and_then(|&i| self.tracks.get(i))
}
fn reindex(&mut self) {
self.by_id = self
.tracks
.iter()
.enumerate()
.map(|(i, t)| (t.id, i))
.collect();
}
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<()> {
let before = self.playlists.len();
self.playlists.retain(|p| p.id != id);
if self.playlists.len() != before {
self.save()?;
}
Ok(())
}
pub fn rename_playlist(&mut self, id: PlaylistId, name: impl Into<String>) -> Result<()> {
if let Some(pl) = self.playlists.iter_mut().find(|p| p.id == id) {
pl.name = name.into();
self.save()?;
}
Ok(())
}
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()?;
}
Ok(())
}
pub fn playlist_remove_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.retain(|&tid| tid != track_id);
self.save()?;
}
Ok(())
}
pub fn playlist_move_track(
&mut self,
playlist_id: PlaylistId,
index: usize,
down: bool,
) -> Option<usize> {
let pl = self.playlists.iter_mut().find(|p| p.id == playlist_id)?;
let target = neighbour(index, down, pl.tracks.len())?;
pl.tracks.swap(index, target);
let _ = self.save();
Some(target)
}
pub fn move_track(&mut self, index: usize, down: bool) -> Option<usize> {
let target = neighbour(index, down, self.tracks.len())?;
self.tracks.swap(index, target);
self.reindex();
let _ = self.save();
Some(target)
}
pub fn move_playlist(&mut self, index: usize, down: bool) -> Option<usize> {
let target = neighbour(index, down, self.playlists.len())?;
self.playlists.swap(index, target);
let _ = self.save();
Some(target)
}
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()
}
}