Initial commit

This commit is contained in:
Agatha Lovelace 2022-09-01 16:35:11 +02:00
commit adec590fa2
Signed by: sorceress
GPG Key ID: 11BBCFC65FC9F401
19 changed files with 3465 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2742
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "eleanor"
version = "0.1.0"
edition = "2021"
authors = ["Agatha Lovelace <agatha@technogothic.net>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adler = "1.0.2"
dirs = "4.0.0"
lofty = "0.7.3"
miette = { version = "5.2.0", features = ["fancy"] }
mime = "0.3.16"
mime_guess = "2.0.4"
paris = { version = "1.5.13", features = ["macros"] }
sea-orm = { version = "0.9.1", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"] }
sea-orm-migration = "^0.9.0"
sea-query = "0.26.2"
serde = { version = "1.0.142", features = ["derive"] }
symphonia = { version = "0.5.1", features = ["flac", "mp3", "vorbis", "ogg", "wav"] }
tokio = { version = "1.20.1", features = ["full"] }
toml = "0.5.9"
walkdir = "2.3.2"

75
src/backend/config.rs Normal file
View File

@ -0,0 +1,75 @@
use std::{fs::File, io::Write};
use super::utils::config_dir;
use miette::{miette, IntoDiagnostic, Result};
use serde::{Deserialize, Serialize};
/// Determines if the files will be loaded from a local path or remotely
#[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 id: u8,
pub name: String,
#[serde(flatten)]
pub source: SourceKind,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
pub cache_expire_days: usize,
pub crossfade: bool,
pub crossfade_duration: u8,
pub song_change_notification: bool,
pub volume: f32,
pub sources: Vec<Source>,
}
impl Config {
pub fn read_config() -> Result<Self> {
let file = config_dir()
.and_then(|v| Some(v.join("settings.toml")))
.ok_or(miette!("Configuration file not found"))?;
let contents = std::fs::read_to_string(file).into_diagnostic()?;
toml::from_str(&contents).into_diagnostic()
}
pub fn write_config(config: &Config) -> Result<()> {
let contents = toml::to_string(config).into_diagnostic()?;
let path = config_dir()
.and_then(|v| Some(v.join("settings.toml")))
.ok_or(miette!("Configuration file not found"))?;
File::create(path)
.and_then(|mut v| v.write_all(contents.as_bytes()))
.into_diagnostic()
}
}
impl Default for Config {
fn default() -> Self {
Config {
cache_expire_days: 30,
crossfade: false,
crossfade_duration: 5,
song_change_notification: false,
volume: 0.5,
sources: vec![Source {
id: 0,
name: "Music".into(),
source: SourceKind::Local {
path: "/home/agatha/Music/local".into(),
},
}],
}
}
}

189
src/backend/fetching.rs Normal file
View File

@ -0,0 +1,189 @@
use std::{
ffi::{OsStr, OsString},
fs::File,
hash::Hasher,
path::Path,
};
use super::{
config::{Config, Source, SourceKind},
model::library,
};
use adler::Adler32;
use lofty::{read_from_path, Accessor, AudioFile};
use miette::{miette, IntoDiagnostic, Result};
use paris::{success, warn};
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()
.and_then(|v| Some(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.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())
.and_then(|t| Some(t.to_string()))),
name: Set(tags
.and_then(|t| t.title())
.and_then(|t| Some(t.to_string()))),
album: Set(tags
.and_then(|t| t.album())
.and_then(|t| Some(t.to_string()))),
genres: Set(tags
.and_then(|t| t.genre())
.and_then(|t| Some(t.to_string()))),
track: Set(tags.and_then(|t| t.track()).and_then(|t| Some(t as i32))),
year: Set(tags.and_then(|t| t.year()).and_then(|t| Some(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(library::Column::Filename)
.do_nothing()
.to_owned(),
)
.exec(db)
.await
.into_diagnostic()?;
}
}
SourceKind::Remote { address } => {
unimplemented!();
}
}
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())
}

View File

@ -0,0 +1,71 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Song::Table)
.if_not_exists()
.col(
ColumnDef::new(Song::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Song::Path).string().not_null())
.col(
ColumnDef::new(Song::Filename)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Song::SourceId).integer().not_null())
.col(ColumnDef::new(Song::Hash).integer().not_null().unique_key())
.col(ColumnDef::new(Song::Artist).string())
.col(ColumnDef::new(Song::Name).string())
.col(ColumnDef::new(Song::Album).string())
.col(ColumnDef::new(Song::Duration).integer().not_null())
.col(ColumnDef::new(Song::Genres).string())
.col(ColumnDef::new(Song::Track).integer())
.col(ColumnDef::new(Song::Year).integer())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Song::Table).to_owned())
.await
}
}
/// A Table containing every indexed song
#[derive(Iden)]
pub enum Song {
#[iden = "library"]
Table,
/// Id of the song
Id,
Path,
Filename,
/// Refers to the sources defined in the configuration file and determines if file is remote
SourceId,
/// A hash of the song's samples as Vec<f32>
Hash,
Artist,
Name,
Album,
Duration,
/// Comma separated list of genres
Genres,
/// Number of the track in the album
Track,
Year,
}

View File

@ -0,0 +1,69 @@
use sea_orm_migration::prelude::*;
use super::{m20220803_000001_create_library::Song, m20220803_000001_create_playlists::Playlist};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(PlaylistEntry::Table)
.if_not_exists()
.col(
ColumnDef::new(PlaylistEntry::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(PlaylistEntry::PlaylistId)
.integer()
.not_null(),
)
.col(ColumnDef::new(PlaylistEntry::SongHash).integer().not_null())
.col(ColumnDef::new(PlaylistEntry::Ordinal).integer())
.col(ColumnDef::new(PlaylistEntry::AddedDate).integer())
.foreign_key(
ForeignKey::create()
.name("fk-playlist-id")
.from(PlaylistEntry::Table, PlaylistEntry::PlaylistId)
.to(Playlist::Table, Playlist::Id),
)
.foreign_key(
ForeignKey::create()
.name("fk-song-hash")
.from(PlaylistEntry::Table, PlaylistEntry::SongHash)
.to(Song::Table, Song::Hash),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(PlaylistEntry::Table).to_owned())
.await
}
}
/// A Table containing mappings between playlist and song
#[derive(Iden)]
pub enum PlaylistEntry {
#[iden = "playlist_entries"]
Table,
Id,
/// Id of playlist containing a song
PlaylistId,
/// Id of the song in the playlist
SongHash,
/// Position of song in playlist
Ordinal,
/// Date when the song was added
AddedDate,
}

View File

@ -0,0 +1,44 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Playlist::Table)
.if_not_exists()
.col(
ColumnDef::new(Playlist::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Playlist::Name).string())
.col(ColumnDef::new(Playlist::SortOrder).string())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Playlist::Table).to_owned())
.await
}
}
/// A Table containing created playlists
#[derive(Iden)]
pub enum Playlist {
#[iden = "playlists"]
Table,
/// Playlist Id
Id,
Name,
SortOrder,
}

View File

@ -0,0 +1,18 @@
use sea_orm_migration::prelude::*;
mod m20220803_000001_create_library;
mod m20220803_000001_create_playlist_entries;
mod m20220803_000001_create_playlists;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220803_000001_create_library::Migration),
Box::new(m20220803_000001_create_playlists::Migration),
Box::new(m20220803_000001_create_playlist_entries::Migration),
]
}
}

38
src/backend/mod.rs Normal file
View File

@ -0,0 +1,38 @@
pub mod config;
pub mod fetching;
mod migrator;
pub mod model;
pub mod playback;
pub mod utils;
use std::fs::{create_dir_all, File};
use miette::{miette, IntoDiagnostic, Result};
use migrator::Migrator;
use paris::success;
use sea_orm_migration::prelude::*;
use self::{config::Config, utils::config_dir};
/// Create the necessary files on first run
pub fn create_app_data() -> Result<()> {
let path = config_dir().ok_or(miette!("Configuration directory does not exist"))?;
// Create Eleanor's config directory
create_dir_all(&path).into_diagnostic()?;
File::create(&path.join("eleanor.db")).into_diagnostic()?;
Config::write_config(&Default::default())?;
success!("Created configuration file");
Ok(())
}
/// Run unapplied migrations
pub async fn prepare_db(db: &sea_orm::DatabaseConnection) -> Result<()> {
Migrator::up(db, None).await.into_diagnostic()?;
success!("Applied migrations");
Ok(())
}

View File

@ -0,0 +1,35 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "library")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub path: String,
pub filename: String,
pub source_id: i32,
pub hash: u64,
pub artist: Option<String>,
pub name: Option<String>,
pub album: Option<String>,
pub duration: u64,
pub genres: Option<String>,
pub track: Option<i32>,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::playlist_entries::Entity")]
PlaylistEntries,
}
impl Related<super::playlist_entries::Entity> for Entity {
fn to() -> RelationDef {
Relation::PlaylistEntries.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

7
src/backend/model/mod.rs Normal file
View File

@ -0,0 +1,7 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
pub mod prelude;
pub mod library;
pub mod playlist_entries;
pub mod playlists;

View File

@ -0,0 +1,48 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "playlist_entries")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub playlist_id: i32,
pub song_hash: i32,
pub ordinal: Option<i32>,
pub added_date: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::library::Entity",
from = "Column::SongHash",
to = "super::library::Column::Hash",
on_update = "NoAction",
on_delete = "NoAction"
)]
Library,
#[sea_orm(
belongs_to = "super::playlists::Entity",
from = "Column::PlaylistId",
to = "super::playlists::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Playlists,
}
impl Related<super::library::Entity> for Entity {
fn to() -> RelationDef {
Relation::Library.def()
}
}
impl Related<super::playlists::Entity> for Entity {
fn to() -> RelationDef {
Relation::Playlists.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,26 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "playlists")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: Option<String>,
pub sort_order: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::playlist_entries::Entity")]
PlaylistEntries,
}
impl Related<super::playlist_entries::Entity> for Entity {
fn to() -> RelationDef {
Relation::PlaylistEntries.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,5 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
pub use super::library::Entity as Library;
pub use super::playlist_entries::Entity as PlaylistEntries;
pub use super::playlists::Entity as Playlists;

0
src/backend/playback.rs Normal file
View File

18
src/backend/utils.rs Normal file
View File

@ -0,0 +1,18 @@
use miette::{miette, Result};
use std::path::PathBuf;
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().and_then(|v| Some(v.join("eleanor")))
}
#[allow(dead_code)]
pub fn cache_dir() -> Option<PathBuf> {
dirs::cache_dir().and_then(|v| Some(v.join("eleanor")))
}
/// If no files have been created in the config directory, the app is running for the first time
pub fn is_first_run() -> Result<bool> {
let path = config_dir().ok_or(miette!("Configuration directory not found"))?;
Ok(!path.exists())
}

0
src/gui/mod.rs Normal file
View File

55
src/main.rs Normal file
View File

@ -0,0 +1,55 @@
use backend::{
create_app_data,
fetching::{index_initial, index_new},
prepare_db,
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 gui;
#[tokio::main]
async fn main() -> Result<()> {
// First, make sure that the app's files exist
let first_run = is_first_run()?;
if first_run {
info!("No previous configuration found; Starting first run process");
create_app_data()?;
}
// Create a database connection
let db: DatabaseConnection = Database::connect(&format!(
"sqlite://{}/eleanor.db?mode=rwc",
config_dir()
.ok_or(miette!("Configuration directory not found"))?
.display()
))
.await
.into_diagnostic()?;
// Run migrations
prepare_db(&db).await?;
let schema_manager = SchemaManager::new(&db);
ensure!(
schema_manager
.has_table("library")
.await
.into_diagnostic()?,
miette!("Running migrations failed")
);
if first_run {
index_initial(&db).await?;
} else {
// Index only new songs
index_new(&db).await?;
}
Ok(())
}