Re-evaluate architecture and attempt rewrite in hopes of defeating scope creep
- symphonia/cpal instead of rodio, as it's too high level - no audio streaming, for now - tracing(-subscriber) instead of paris, as it isn't very useful for debugging - KDL instead of TOML, as it's somewhat clunky, even for small config files
This commit is contained in:
parent
ad29628af7
commit
329ff463ed
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
32
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "eleanor"
|
name = "eleanor"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Agatha Lovelace <agatha@technogothic.net>"]
|
authors = ["Agatha Lovelace <agatha@technogothic.net>"]
|
||||||
|
|
||||||
|
@ -8,19 +8,19 @@ authors = ["Agatha Lovelace <agatha@technogothic.net>"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
adler = "1.0.2"
|
adler = "1.0.2"
|
||||||
dirs = "4.0.0"
|
chrono = "0.4.31"
|
||||||
lofty = "0.7.3"
|
dirs = "5.0.1"
|
||||||
miette = { version = "5.2.0", features = ["fancy"] }
|
kdl = "4.6.0"
|
||||||
mime = "0.3.16"
|
miette = { version = "5.10.0", features = ["fancy"] }
|
||||||
|
mime = "0.3.17"
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
paris = { version = "1.5.13", features = ["macros"] }
|
owo-colors = "3.5.0"
|
||||||
reqwest = "0.11.12"
|
sea-orm = { version = "0.12.4", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"] }
|
||||||
rmp-serde = "1.1.0"
|
sea-orm-migration = "0.12.4"
|
||||||
sea-orm = { version = "0.9.1", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"] }
|
sea-query = "0.30.2"
|
||||||
sea-orm-migration = "^0.9.0"
|
symphonia = { version = "0.5.3", features = ["flac", "mp3", "vorbis", "ogg", "wav"] }
|
||||||
sea-query = "0.26.2"
|
tokio = { version = "1.33.0", features = ["full"] }
|
||||||
serde = { version = "1.0.142", features = ["derive"] }
|
tracing = { version = "0.1.40" }
|
||||||
symphonia = { version = "0.5.1", features = ["flac", "mp3", "vorbis", "ogg", "wav"] }
|
tracing-core = "0.1.32"
|
||||||
tokio = { version = "1.20.1", features = ["full"] }
|
tracing-subscriber = "0.3.17"
|
||||||
toml = "0.5.9"
|
walkdir = "2.4.0"
|
||||||
walkdir = "2.3.2"
|
|
||||||
|
|
|
@ -1,54 +1,93 @@
|
||||||
use std::{fs::File, io::Write};
|
use std::{fmt::Display, fs::File, io::Write};
|
||||||
|
|
||||||
use super::utils::config_dir;
|
use super::{
|
||||||
|
kdl_utils::{KdlDocumentExt, KdlNodeExt},
|
||||||
|
utils::config_dir,
|
||||||
|
};
|
||||||
|
use kdl::{KdlDocument, KdlNode};
|
||||||
use miette::{miette, IntoDiagnostic, Result};
|
use miette::{miette, IntoDiagnostic, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Determines if the files will be loaded from a local path or remotely
|
#[derive(Debug)]
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum SourceKind {
|
|
||||||
/// Path to a directory
|
|
||||||
Local { path: String },
|
|
||||||
/// Remote server address
|
|
||||||
Remote { address: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct Source {
|
pub struct Source {
|
||||||
pub id: u8,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(flatten)]
|
pub path: String,
|
||||||
pub source: SourceKind,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Debug)]
|
||||||
#[serde(default)]
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub cache_expire_days: usize,
|
|
||||||
pub crossfade: bool,
|
pub crossfade: bool,
|
||||||
pub crossfade_duration: u8,
|
pub crossfade_duration: u32,
|
||||||
pub song_change_notification: bool,
|
pub song_change_notification: bool,
|
||||||
pub volume: f32,
|
pub volume: f64,
|
||||||
pub sources: Vec<Source>,
|
pub sources: Vec<Source>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Config {
|
||||||
|
crossfade: false,
|
||||||
|
crossfade_duration: 5,
|
||||||
|
song_change_notification: false,
|
||||||
|
volume: 0.5,
|
||||||
|
sources: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn read_config() -> Result<Self> {
|
pub fn read_config() -> Result<Self> {
|
||||||
let file = config_dir()
|
let file = config_dir()
|
||||||
.map(|v| v.join("settings.toml"))
|
.map(|v| v.join("settings.kdl"))
|
||||||
.ok_or(miette!("Configuration file not found"))?;
|
.ok_or(miette!("Configuration file not found"))?;
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(file).into_diagnostic()?;
|
let contents = std::fs::read_to_string(file).into_diagnostic()?;
|
||||||
|
let kdl_doc: KdlDocument = contents.parse()?;
|
||||||
|
|
||||||
toml::from_str(&contents).into_diagnostic()
|
let playback = kdl_doc.get_children_or("playback", KdlDocument::new());
|
||||||
|
|
||||||
|
// Fallback for values added in future versions
|
||||||
|
let default = Self::default();
|
||||||
|
|
||||||
|
let crossfade = playback.get_bool_or("crossfade", default.crossfade);
|
||||||
|
|
||||||
|
let crossfade_duration =
|
||||||
|
playback.get_u32_or("crossfade-duration", default.crossfade_duration);
|
||||||
|
|
||||||
|
let volume = playback.get_f64_or("volume", default.volume);
|
||||||
|
|
||||||
|
let song_change_notification =
|
||||||
|
kdl_doc.get_bool_or("song-change-notification", default.song_change_notification);
|
||||||
|
|
||||||
|
let sources = kdl_doc
|
||||||
|
.get("sources")
|
||||||
|
.and_then(KdlNode::children)
|
||||||
|
.map(KdlDocument::nodes)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let sources = if sources.is_empty() {
|
||||||
|
default.sources
|
||||||
|
} else {
|
||||||
|
sources
|
||||||
|
.iter()
|
||||||
|
.map(Source::try_from_node)
|
||||||
|
.collect::<Result<_>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
crossfade,
|
||||||
|
crossfade_duration,
|
||||||
|
song_change_notification,
|
||||||
|
volume,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_config(config: &Config) -> Result<()> {
|
pub fn write_config(config: &Config) -> Result<()> {
|
||||||
let contents = toml::to_string(config).into_diagnostic()?;
|
let contents = config.to_string();
|
||||||
|
|
||||||
let path = config_dir()
|
let path = config_dir()
|
||||||
.map(|v| v.join("settings.toml"))
|
.map(|v| v.join("settings.kdl"))
|
||||||
.ok_or(miette!("Configuration file not found"))?;
|
.ok_or(miette!("Configuration file not found"))?;
|
||||||
|
|
||||||
File::create(path)
|
File::create(path)
|
||||||
|
@ -57,22 +96,67 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Initialize sources to empty list instead
|
impl Display for Config {
|
||||||
impl Default for Config {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
fn default() -> Self {
|
let mut kdl_doc = KdlDocument::new();
|
||||||
Config {
|
|
||||||
cache_expire_days: 30,
|
let song_change_notification =
|
||||||
crossfade: false,
|
KdlNode::with_arg("song-change-notification", self.song_change_notification);
|
||||||
crossfade_duration: 5,
|
|
||||||
song_change_notification: false,
|
let playback = KdlNode::new("playback")
|
||||||
volume: 0.5,
|
.add_child(KdlNode::with_arg("crossfade", self.crossfade))
|
||||||
sources: vec![Source {
|
.add_child(
|
||||||
id: 0,
|
KdlNode::new("crossfade-duration")
|
||||||
name: "Music".into(),
|
.add_arg(i64::from(self.crossfade_duration), Some("sec"))
|
||||||
source: SourceKind::Local {
|
.clone(),
|
||||||
path: "/home/agatha/Music/local".into(),
|
)
|
||||||
},
|
.add_child(KdlNode::with_arg("volume", self.volume))
|
||||||
}],
|
.clone();
|
||||||
|
|
||||||
|
let mut sources = KdlNode::new("sources");
|
||||||
|
for source in &self.sources {
|
||||||
|
sources.add_child(
|
||||||
|
KdlNode::new(source.name.clone())
|
||||||
|
.set_param("id", i64::from(source.id))
|
||||||
|
.set_param("path", source.path.clone())
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kdl_doc
|
||||||
|
.add_child(song_change_notification)
|
||||||
|
.add_child(playback)
|
||||||
|
.add_child(sources);
|
||||||
|
|
||||||
|
f.write_str(&kdl_doc.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
fn try_from_node(node: &KdlNode) -> Result<Self> {
|
||||||
|
let name = node.name().value().to_owned();
|
||||||
|
|
||||||
|
let id = node
|
||||||
|
.get("id")
|
||||||
|
.ok_or(miette!(format!(
|
||||||
|
"Source {name} is missing an `id` parameter"
|
||||||
|
)))?
|
||||||
|
.value()
|
||||||
|
.as_i64()
|
||||||
|
.ok_or(miette!("Source id must be a number"))?
|
||||||
|
.try_into()
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
let path = node
|
||||||
|
.get("path")
|
||||||
|
.ok_or(miette!(format!(
|
||||||
|
"Source {name} is missing a `path` parameter"
|
||||||
|
)))?
|
||||||
|
.value()
|
||||||
|
.as_string()
|
||||||
|
.ok_or(miette!("Source path must be a string"))?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
Ok(Self { id, name, path })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,235 +0,0 @@
|
||||||
use std::{
|
|
||||||
ffi::{OsStr, OsString},
|
|
||||||
fs::File,
|
|
||||||
hash::Hasher,
|
|
||||||
path::Path,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::backend::utils::get_auth_source;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
config::{Config, Source, SourceKind},
|
|
||||||
model::{library, library::Column},
|
|
||||||
};
|
|
||||||
use adler::Adler32;
|
|
||||||
use lofty::{read_from_path, Accessor, AudioFile};
|
|
||||||
use miette::{miette, IntoDiagnostic, Result};
|
|
||||||
use paris::{success, warn};
|
|
||||||
use reqwest::Client;
|
|
||||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set};
|
|
||||||
use symphonia::{
|
|
||||||
core::{
|
|
||||||
io::MediaSourceStream,
|
|
||||||
meta::{Limit, MetadataOptions},
|
|
||||||
probe::Hint,
|
|
||||||
},
|
|
||||||
default::get_probe,
|
|
||||||
};
|
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
#[derive(PartialEq, Debug)]
|
|
||||||
pub enum IndexMode {
|
|
||||||
Purge,
|
|
||||||
New,
|
|
||||||
Initial,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn index_source(source: Source, mode: IndexMode, db: &DatabaseConnection) -> Result<()> {
|
|
||||||
let mut existing: Vec<OsString> = vec![];
|
|
||||||
|
|
||||||
// Force reindex source
|
|
||||||
if mode == IndexMode::Purge {
|
|
||||||
warn!("Overwriting source {}", source.id);
|
|
||||||
|
|
||||||
library::Entity::delete_many()
|
|
||||||
.filter(library::Column::SourceId.eq(source.id))
|
|
||||||
.exec(db)
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?;
|
|
||||||
// Only index new songs
|
|
||||||
} else if mode == IndexMode::New {
|
|
||||||
existing = library::Entity::find()
|
|
||||||
.filter(library::Column::SourceId.eq(source.id))
|
|
||||||
.column(library::Column::Filename)
|
|
||||||
.all(db)
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|v| v.filename.into())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
match source.source {
|
|
||||||
SourceKind::Local { path } => {
|
|
||||||
for file in WalkDir::new(path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.filter(|e| !e.file_type().is_dir())
|
|
||||||
.filter(|e| {
|
|
||||||
mime_guess::from_path(e.path())
|
|
||||||
.first()
|
|
||||||
.map(|v| v.type_() == mime::AUDIO)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
if mode == IndexMode::New {
|
|
||||||
if existing.contains(&file.file_name().into()) {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio = read_from_path(file.path(), true).into_diagnostic()?;
|
|
||||||
|
|
||||||
let tags = audio.primary_tag().or(audio.first_tag());
|
|
||||||
|
|
||||||
let properties = audio.properties();
|
|
||||||
|
|
||||||
let hash = hash_file(file.path())?;
|
|
||||||
|
|
||||||
let song: library::ActiveModel = library::ActiveModel {
|
|
||||||
path: Set(file
|
|
||||||
.path()
|
|
||||||
.parent()
|
|
||||||
.and_then(Path::to_str)
|
|
||||||
.ok_or(miette!("Couldn't get path for file {:?}", file))?
|
|
||||||
.to_string()),
|
|
||||||
filename: Set(file
|
|
||||||
.file_name()
|
|
||||||
.to_str()
|
|
||||||
.ok_or(miette!("Couldn't get filename for file {:?}", file))?
|
|
||||||
.to_string()),
|
|
||||||
source_id: Set(source.id.into()),
|
|
||||||
hash: Set(hash.try_into().into_diagnostic()?),
|
|
||||||
artist: Set(tags.and_then(|t| t.artist()).map(|t| t.to_string())),
|
|
||||||
album_artist: Set(tags
|
|
||||||
.and_then(|t| t.get_string(&lofty::ItemKey::AlbumArtist))
|
|
||||||
.map(|t| t.to_string())),
|
|
||||||
name: Set(tags.and_then(|t| t.title()).map(|t| t.to_string())),
|
|
||||||
album: Set(tags.and_then(|t| t.album()).map(|t| t.to_string())),
|
|
||||||
genres: Set(tags.and_then(|t| t.genre()).map(|t| t.to_string())),
|
|
||||||
track: Set(tags.and_then(|t| t.track()).map(|t| t as i32)),
|
|
||||||
year: Set(tags.and_then(|t| t.year()).map(|t| t as i32)),
|
|
||||||
duration: Set(properties
|
|
||||||
.duration()
|
|
||||||
.as_millis()
|
|
||||||
.try_into()
|
|
||||||
.into_diagnostic()?),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
library::Entity::insert(song)
|
|
||||||
.on_conflict(
|
|
||||||
sea_query::OnConflict::column(Column::Hash)
|
|
||||||
.do_nothing()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(db)
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SourceKind::Remote { address } => {
|
|
||||||
let (username, password) = get_auth_source(source.id)?;
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
let index = client
|
|
||||||
.get(format!("{address}/"))
|
|
||||||
.basic_auth(username, Some(password))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?;
|
|
||||||
|
|
||||||
// Deserialize messagepack into a library model
|
|
||||||
let parsed: Vec<library::Model> = rmp_serde::from_slice(&index).into_diagnostic()?;
|
|
||||||
|
|
||||||
// Use all fields except for id and source_id
|
|
||||||
let songs: Vec<_> = parsed
|
|
||||||
.into_iter()
|
|
||||||
.map(|v| {
|
|
||||||
return library::ActiveModel {
|
|
||||||
path: Set(v.path),
|
|
||||||
filename: Set(v.filename),
|
|
||||||
source_id: Set(source.id.into()), // Use local source id, not remote
|
|
||||||
hash: Set(v.hash),
|
|
||||||
artist: Set(v.artist),
|
|
||||||
album_artist: Set(v.album_artist),
|
|
||||||
name: Set(v.name),
|
|
||||||
album: Set(v.album),
|
|
||||||
genres: Set(v.genres),
|
|
||||||
track: Set(v.track),
|
|
||||||
year: Set(v.year),
|
|
||||||
duration: Set(v.duration),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
library::Entity::insert_many(songs)
|
|
||||||
.on_conflict(
|
|
||||||
sea_query::OnConflict::column(Column::Hash)
|
|
||||||
.do_nothing()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(db)
|
|
||||||
.await
|
|
||||||
.into_diagnostic()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
success!("Indexed source {} in {:?} mode", source.id, mode);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn index_initial(db: &DatabaseConnection) -> Result<()> {
|
|
||||||
let sources = Config::read_config()?.sources;
|
|
||||||
|
|
||||||
for source in sources {
|
|
||||||
index_source(source, IndexMode::Initial, db).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn index_new(db: &DatabaseConnection) -> Result<()> {
|
|
||||||
let sources = Config::read_config()?.sources;
|
|
||||||
|
|
||||||
for source in sources {
|
|
||||||
index_source(source, IndexMode::New, db).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_file(path: &Path) -> Result<u64> {
|
|
||||||
let file = Box::new(File::open(path).into_diagnostic()?);
|
|
||||||
|
|
||||||
let probe = get_probe();
|
|
||||||
|
|
||||||
let ext = path.extension().and_then(OsStr::to_str).unwrap_or("");
|
|
||||||
|
|
||||||
let source = MediaSourceStream::new(file, Default::default());
|
|
||||||
let mut data = probe
|
|
||||||
.format(
|
|
||||||
&Hint::new().with_extension(ext),
|
|
||||||
source,
|
|
||||||
&Default::default(),
|
|
||||||
&MetadataOptions {
|
|
||||||
limit_metadata_bytes: Limit::Maximum(0),
|
|
||||||
limit_visual_bytes: Limit::Maximum(0),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.into_diagnostic()?
|
|
||||||
.format;
|
|
||||||
|
|
||||||
let mut adler = Adler32::new();
|
|
||||||
|
|
||||||
while let Ok(packet) = data.next_packet() {
|
|
||||||
adler.write(&packet.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(adler.finish())
|
|
||||||
}
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
//! Helper functions for interacting with KDL documents.
|
||||||
|
|
||||||
|
use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
|
||||||
|
|
||||||
|
pub(crate) trait KdlNodeExt {
|
||||||
|
fn first_arg(&self) -> Option<KdlValue>;
|
||||||
|
|
||||||
|
fn add_arg<T: Into<KdlValue>>(&mut self, value: T, ty: Option<&str>) -> &mut Self;
|
||||||
|
|
||||||
|
fn add_child(&mut self, node: KdlNode) -> &mut Self;
|
||||||
|
|
||||||
|
fn set_param<T: Into<KdlValue>>(&mut self, key: &str, value: T) -> &mut Self;
|
||||||
|
|
||||||
|
fn with_arg<T: Into<KdlValue>>(name: &str, value: T) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdlNodeExt for KdlNode {
|
||||||
|
fn first_arg(&self) -> Option<KdlValue> {
|
||||||
|
self.entries()
|
||||||
|
.iter()
|
||||||
|
.find(|node| node.name().is_none())
|
||||||
|
.map(KdlEntry::value)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_arg<T: Into<KdlValue>>(&mut self, value: T, ty: Option<&str>) -> &mut Self {
|
||||||
|
let mut arg = KdlEntry::new(value);
|
||||||
|
if let Some(ty) = ty {
|
||||||
|
arg.set_ty(ty);
|
||||||
|
}
|
||||||
|
self.push(arg);
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_child(&mut self, node: KdlNode) -> &mut Self {
|
||||||
|
if let Some(children) = self.children_mut() {
|
||||||
|
children.add_child(node);
|
||||||
|
} else {
|
||||||
|
self.set_children(KdlDocument::new().add_child(node).clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param<T: Into<KdlValue>>(&mut self, key: &str, value: T) -> &mut Self {
|
||||||
|
self.insert(key, value);
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_arg<T: Into<KdlValue>>(name: &str, value: T) -> Self {
|
||||||
|
KdlNode::new(name).add_arg(value, None).clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait KdlDocumentExt {
|
||||||
|
fn get_bool_or(&self, name: &str, default: bool) -> bool;
|
||||||
|
|
||||||
|
fn get_u32_or(&self, name: &str, default: u32) -> u32;
|
||||||
|
|
||||||
|
fn get_f64_or(&self, name: &str, default: f64) -> f64;
|
||||||
|
|
||||||
|
fn get_children_or(&self, name: &str, default: KdlDocument) -> KdlDocument;
|
||||||
|
|
||||||
|
fn add_child(&mut self, node: KdlNode) -> &mut Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdlDocumentExt for KdlDocument {
|
||||||
|
fn get_bool_or(&self, name: &str, default: bool) -> bool {
|
||||||
|
self.get(name)
|
||||||
|
.and_then(KdlNode::first_arg)
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_u32_or(&self, name: &str, default: u32) -> u32 {
|
||||||
|
self.get(name)
|
||||||
|
.and_then(KdlNode::first_arg)
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.and_then(|v| v.try_into().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_f64_or(&self, name: &str, default: f64) -> f64 {
|
||||||
|
self.get(name)
|
||||||
|
.and_then(KdlNode::first_arg)
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_children_or(&self, name: &str, default: KdlDocument) -> KdlDocument {
|
||||||
|
self.get(name)
|
||||||
|
.and_then(|v| v.children().cloned())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_child(&mut self, node: KdlNode) -> &mut Self {
|
||||||
|
self.nodes_mut().push(node);
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use owo_colors::{AnsiColors, OwoColorize};
|
||||||
|
use std::io::{stdout, IsTerminal};
|
||||||
|
use std::{env, fmt};
|
||||||
|
use tracing_core::{Event, Level, LevelFilter, Subscriber};
|
||||||
|
use tracing_subscriber::{
|
||||||
|
field::MakeExt,
|
||||||
|
fmt::{
|
||||||
|
format::{self, format, FormatEvent, FormatFields},
|
||||||
|
FmtContext,
|
||||||
|
},
|
||||||
|
layer::SubscriberExt,
|
||||||
|
registry::LookupSpan,
|
||||||
|
util::SubscriberInitExt,
|
||||||
|
EnvFilter, Layer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Less noisy formatter for tracing-subscriber
|
||||||
|
pub struct PrettyFormatter {
|
||||||
|
timer: DateTime<Local>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PrettyFormatter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timer: Local::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, N> FormatEvent<S, N> for PrettyFormatter
|
||||||
|
where
|
||||||
|
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||||
|
N: for<'a> FormatFields<'a> + 'static,
|
||||||
|
{
|
||||||
|
fn format_event(
|
||||||
|
&self,
|
||||||
|
ctx: &FmtContext<'_, S, N>,
|
||||||
|
mut writer: format::Writer<'_>,
|
||||||
|
event: &Event<'_>,
|
||||||
|
) -> fmt::Result {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
|
||||||
|
let timestamp = if writer.has_ansi_escapes() {
|
||||||
|
self.timer
|
||||||
|
.format("%H:%M:%S")
|
||||||
|
.color(AnsiColors::BrightBlack)
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
self.timer.format("%H:%M:%S").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
write!(writer, "{timestamp} ")?;
|
||||||
|
|
||||||
|
let (level_icon, level_style) = match *metadata.level() {
|
||||||
|
Level::TRACE => ('…', AnsiColors::Magenta),
|
||||||
|
Level::DEBUG => (' ', AnsiColors::White),
|
||||||
|
Level::INFO => ('ℹ', AnsiColors::Blue),
|
||||||
|
Level::WARN => ('⚠', AnsiColors::BrightYellow),
|
||||||
|
Level::ERROR => ('✖', AnsiColors::Red),
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon = if writer.has_ansi_escapes() {
|
||||||
|
level_icon.color(level_style).to_string()
|
||||||
|
} else {
|
||||||
|
level_icon.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
write!(writer, "{icon} ")?;
|
||||||
|
|
||||||
|
ctx.field_format().format_fields(writer.by_ref(), event)?;
|
||||||
|
|
||||||
|
writeln!(writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up tracing-subscriber
|
||||||
|
pub fn setup() {
|
||||||
|
// default to INFO for release builds, DEBUG otherwise
|
||||||
|
const LEVEL: LevelFilter = if cfg!(debug_assertions) {
|
||||||
|
LevelFilter::DEBUG
|
||||||
|
} else {
|
||||||
|
LevelFilter::INFO
|
||||||
|
};
|
||||||
|
|
||||||
|
// Newline-separated fields
|
||||||
|
let field_fmt = tracing_subscriber::fmt::format::debug_fn(|writer, field, value| {
|
||||||
|
write!(
|
||||||
|
writer,
|
||||||
|
"{}{:?}",
|
||||||
|
if field.name() == "message" {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{field}: ")
|
||||||
|
},
|
||||||
|
value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.delimited("\n\t · ");
|
||||||
|
|
||||||
|
let output = match env::var("ELEANOR_VERBOSE") {
|
||||||
|
Ok(_) => tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(stdout().is_terminal())
|
||||||
|
.event_format(format())
|
||||||
|
.boxed(),
|
||||||
|
// `ELEANOR_VERBOSE` is not set, default to pretty logs
|
||||||
|
Err(_) => tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(stdout().is_terminal())
|
||||||
|
.event_format(PrettyFormatter::default())
|
||||||
|
.fmt_fields(field_fmt)
|
||||||
|
.boxed(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let log = tracing_subscriber::registry().with(output);
|
||||||
|
|
||||||
|
if env::var("RUST_LOG").is_ok_and(|v| !v.is_empty()) {
|
||||||
|
log.with(EnvFilter::from_default_env()).init();
|
||||||
|
} else {
|
||||||
|
log.with(LEVEL).init();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,23 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod fetching;
|
#[allow(clippy::pedantic)]
|
||||||
mod migrator;
|
mod migrator;
|
||||||
|
#[allow(clippy::pedantic)]
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod playback;
|
// pub mod playback;
|
||||||
|
mod kdl_utils;
|
||||||
|
pub mod logging;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use std::fs::{create_dir_all, File};
|
use std::fs::{create_dir_all, File};
|
||||||
|
|
||||||
use miette::{miette, IntoDiagnostic, Result};
|
use miette::{miette, IntoDiagnostic, Result};
|
||||||
use migrator::Migrator;
|
use migrator::Migrator;
|
||||||
use paris::success;
|
|
||||||
use sea_orm_migration::prelude::*;
|
use sea_orm_migration::prelude::*;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use self::{
|
use crate::backend::config::Config;
|
||||||
config::Config,
|
|
||||||
utils::{cache_dir, config_dir},
|
use self::utils::{cache_dir, config_dir};
|
||||||
};
|
|
||||||
|
|
||||||
/// Create the necessary files on first run
|
/// Create the necessary files on first run
|
||||||
pub fn create_app_data() -> Result<()> {
|
pub fn create_app_data() -> Result<()> {
|
||||||
|
@ -27,11 +29,11 @@ pub fn create_app_data() -> Result<()> {
|
||||||
let cache_path = cache_dir().ok_or(miette!("Configuration directory does not exist"))?;
|
let cache_path = cache_dir().ok_or(miette!("Configuration directory does not exist"))?;
|
||||||
|
|
||||||
// Create Eleanor's cache directory
|
// Create Eleanor's cache directory
|
||||||
create_dir_all(&cache_path).into_diagnostic()?;
|
create_dir_all(cache_path).into_diagnostic()?;
|
||||||
|
|
||||||
File::create(&config_path.join("eleanor.db")).into_diagnostic()?;
|
File::create(config_path.join("eleanor.db")).into_diagnostic()?;
|
||||||
Config::write_config(&Default::default())?;
|
Config::write_config(&Config::default())?;
|
||||||
success!("Created configuration file");
|
info!("Created configuration file");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -40,7 +42,7 @@ pub fn create_app_data() -> Result<()> {
|
||||||
pub async fn prepare_db(db: &sea_orm::DatabaseConnection) -> Result<()> {
|
pub async fn prepare_db(db: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||||
Migrator::up(db, None).await.into_diagnostic()?;
|
Migrator::up(db, None).await.into_diagnostic()?;
|
||||||
|
|
||||||
success!("Applied migrations");
|
info!("Applied migrations");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
/// An audio track
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
#[sea_orm(table_name = "library")]
|
#[sea_orm(table_name = "library")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key)]
|
||||||
|
@ -12,7 +12,9 @@ pub struct Model {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub source_id: i32,
|
pub source_id: i32,
|
||||||
pub hash: u32,
|
pub hash: u32,
|
||||||
|
// TODO: should be many-to-many
|
||||||
pub artist: Option<String>,
|
pub artist: Option<String>,
|
||||||
|
// TODO: should be many-to-many
|
||||||
pub album_artist: Option<String>,
|
pub album_artist: Option<String>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
#[sea_orm(table_name = "playlist_entries")]
|
#[sea_orm(table_name = "playlist_entries")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key)]
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
#[sea_orm(table_name = "playlists")]
|
#[sea_orm(table_name = "playlists")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key)]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use miette::{miette, IntoDiagnostic, Result};
|
use miette::{miette, Result};
|
||||||
use std::{fs::File, io::Write, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub fn config_dir() -> Option<PathBuf> {
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|v| v.join("eleanor"))
|
dirs::config_dir().map(|v| v.join("eleanor"))
|
||||||
|
@ -15,29 +15,3 @@ pub fn is_first_run() -> Result<bool> {
|
||||||
|
|
||||||
Ok(!path.exists())
|
Ok(!path.exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores credentials for a remote source
|
|
||||||
pub fn store_auth_source(username: String, password: String, source: u8) -> Result<()> {
|
|
||||||
let contents = rmp_serde::to_vec(&(username, password)).into_diagnostic()?;
|
|
||||||
|
|
||||||
let path = cache_dir()
|
|
||||||
.ok_or(miette!("Cache directory does not exist"))?
|
|
||||||
.join(format!("{source}.auth"));
|
|
||||||
|
|
||||||
File::create(path)
|
|
||||||
.and_then(|mut v| v.write_all(&contents))
|
|
||||||
.into_diagnostic()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the stored credentials for a remote source
|
|
||||||
pub fn get_auth_source(source: u8) -> Result<(String, String)> {
|
|
||||||
let path = cache_dir()
|
|
||||||
.ok_or(miette!("Cache directory does not exist"))?
|
|
||||||
.join(format!("{source}.auth"));
|
|
||||||
|
|
||||||
let file = std::fs::read(path).into_diagnostic()?;
|
|
||||||
|
|
||||||
let contents = rmp_serde::from_slice(&file).into_diagnostic()?;
|
|
||||||
|
|
||||||
Ok(contents)
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
|
44
src/main.rs
44
src/main.rs
|
@ -1,19 +1,29 @@
|
||||||
use backend::{
|
use miette::{ensure, miette, IntoDiagnostic, Result};
|
||||||
create_app_data,
|
|
||||||
fetching::{index_initial, index_new},
|
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||||
prepare_db,
|
use sea_orm_migration::SchemaManager;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::backend::{
|
||||||
|
create_app_data, logging, prepare_db,
|
||||||
utils::{config_dir, is_first_run},
|
utils::{config_dir, is_first_run},
|
||||||
};
|
};
|
||||||
use miette::{ensure, miette, IntoDiagnostic, Result};
|
|
||||||
use paris::info;
|
|
||||||
use sea_orm::{Database, DatabaseConnection};
|
|
||||||
use sea_orm_migration::SchemaManager;
|
|
||||||
|
|
||||||
mod backend;
|
mod backend;
|
||||||
mod gui;
|
mod gui;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() {
|
||||||
|
if let Err(e) = startup().await {
|
||||||
|
eprintln!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate function to avoid the main function error message prefix
|
||||||
|
async fn startup() -> Result<()> {
|
||||||
|
logging::setup();
|
||||||
|
|
||||||
// First, make sure that the app's files exist
|
// First, make sure that the app's files exist
|
||||||
let first_run = is_first_run()?;
|
let first_run = is_first_run()?;
|
||||||
if first_run {
|
if first_run {
|
||||||
|
@ -22,14 +32,15 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a database connection
|
// Create a database connection
|
||||||
let db: DatabaseConnection = Database::connect(&format!(
|
let mut conn = ConnectOptions::new(format!(
|
||||||
"sqlite://{}/eleanor.db?mode=rwc",
|
"sqlite://{}/eleanor.db?mode=rwc",
|
||||||
config_dir()
|
config_dir()
|
||||||
.ok_or(miette!("Configuration directory not found"))?
|
.ok_or(miette!("Configuration directory not found"))?
|
||||||
.display()
|
.display()
|
||||||
))
|
));
|
||||||
.await
|
conn.sqlx_logging_level(tracing::log::LevelFilter::Trace);
|
||||||
.into_diagnostic()?;
|
|
||||||
|
let db: DatabaseConnection = Database::connect(conn).await.into_diagnostic()?;
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
prepare_db(&db).await?;
|
prepare_db(&db).await?;
|
||||||
|
@ -44,12 +55,5 @@ async fn main() -> Result<()> {
|
||||||
miette!("Running migrations failed")
|
miette!("Running migrations failed")
|
||||||
);
|
);
|
||||||
|
|
||||||
if first_run {
|
|
||||||
index_initial(&db).await?;
|
|
||||||
} else {
|
|
||||||
// Index only new songs
|
|
||||||
index_new(&db).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue