V1 Server implementation
This commit is contained in:
commit
ca92edef42
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
settings.toml
|
||||||
|
eleanor-server.db
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "eleanor-server"
|
||||||
|
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"
|
||||||
|
argon2 = "0.4.1"
|
||||||
|
axum = "0.5.15"
|
||||||
|
axum-auth = "0.3.0"
|
||||||
|
clap = { version = "3.2.20", features = ["cargo"] }
|
||||||
|
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"] }
|
||||||
|
rand_core = { version = "0.6.3", features = ["std"] }
|
||||||
|
rmp-serde = "1.1.0"
|
||||||
|
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"
|
||||||
|
tower = "0.4.13"
|
||||||
|
walkdir = "2.3.2"
|
|
@ -0,0 +1,50 @@
|
||||||
|
use std::{fs::File, io::Write};
|
||||||
|
|
||||||
|
use miette::{IntoDiagnostic, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Source {
|
||||||
|
pub id: u8,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub port: u16,
|
||||||
|
pub sources: Vec<Source>,
|
||||||
|
}
|
||||||
|
impl Config {
|
||||||
|
pub fn read_config() -> Result<Self> {
|
||||||
|
let file = std::env::current_dir()
|
||||||
|
.and_then(|v| Ok(v.join("settings.toml")))
|
||||||
|
.into_diagnostic()?;
|
||||||
|
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 = std::env::current_dir()
|
||||||
|
.and_then(|v| Ok(v.join("settings.toml")))
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
File::create(path)
|
||||||
|
.and_then(|mut v| v.write_all(contents.as_bytes()))
|
||||||
|
.into_diagnostic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Config {
|
||||||
|
port: 8008,
|
||||||
|
sources: vec![Source {
|
||||||
|
id: 0,
|
||||||
|
path: "".into(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,190 @@
|
||||||
|
use std::{
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
|
fs::File,
|
||||||
|
hash::Hasher,
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::config::{Config, Source};
|
||||||
|
use super::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();
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in WalkDir::new(source.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()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reindex(db: &DatabaseConnection) -> Result<()> {
|
||||||
|
let sources = Config::read_config()?.sources;
|
||||||
|
|
||||||
|
for source in sources {
|
||||||
|
index_source(source, IndexMode::Purge, 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,128 @@
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
use clap::{arg, command, Command};
|
||||||
|
use config::Config;
|
||||||
|
use miette::{ensure, miette, IntoDiagnostic, Result};
|
||||||
|
use paris::info;
|
||||||
|
use sea_orm::{Database, DatabaseConnection};
|
||||||
|
use sea_orm_migration::SchemaManager;
|
||||||
|
use server::{add_user, remove_user, routes};
|
||||||
|
use {
|
||||||
|
fetching::{index_initial, index_new},
|
||||||
|
utils::{create_app_data, is_first_run, prepare_db},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod fetching;
|
||||||
|
mod migrator;
|
||||||
|
mod model;
|
||||||
|
mod server;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let matches = command!()
|
||||||
|
.version("0.1")
|
||||||
|
.author("Agatha V. Lovelace")
|
||||||
|
.subcommand(
|
||||||
|
Command::new("user")
|
||||||
|
.about("Manage users")
|
||||||
|
.subcommand_required(true)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("add")
|
||||||
|
.about("Add a user")
|
||||||
|
.arg(arg!(<USERNAME>))
|
||||||
|
.arg(arg!(<PASSWORD>)),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("remove")
|
||||||
|
.about("Remove a user")
|
||||||
|
.arg(arg!(<USERNAME>)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get_matches();
|
||||||
|
|
||||||
|
// 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-server.db?mode=rwc",
|
||||||
|
std::env::current_dir().into_diagnostic()?.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")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle user management
|
||||||
|
match matches.subcommand() {
|
||||||
|
Some(("user", args)) => {
|
||||||
|
match args.subcommand() {
|
||||||
|
Some(("add", args)) => {
|
||||||
|
add_user(
|
||||||
|
&db,
|
||||||
|
args.get_one::<String>("USERNAME")
|
||||||
|
.ok_or(miette!("No username provided"))?
|
||||||
|
.to_string(),
|
||||||
|
args.get_one::<String>("PASSWORD")
|
||||||
|
.ok_or(miette!("No password provided"))?
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(("remove", args)) => {
|
||||||
|
remove_user(
|
||||||
|
&db,
|
||||||
|
args.get_one::<String>("USERNAME")
|
||||||
|
.ok_or(miette!("No username provided"))?
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
process::exit(0);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
if first_run {
|
||||||
|
index_initial(&db).await?;
|
||||||
|
} else {
|
||||||
|
// Index only new songs
|
||||||
|
index_new(&db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config::read_config()?;
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
info!("Serving on port {}", config.port);
|
||||||
|
|
||||||
|
axum::Server::bind(
|
||||||
|
&format!("0.0.0.0:{}", config.port)
|
||||||
|
.parse()
|
||||||
|
.into_diagnostic()?,
|
||||||
|
)
|
||||||
|
.serve(routes(db).into_make_service())
|
||||||
|
.await
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
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(User::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(User::Name).string().not_null().unique_key())
|
||||||
|
.col(ColumnDef::new(User::Password).string().not_null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
pub enum User {
|
||||||
|
#[iden = "users"]
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Password,
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
mod m20220815_000001_create_library;
|
||||||
|
mod m20229094_000001_create_users;
|
||||||
|
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20220815_000001_create_library::Migration),
|
||||||
|
Box::new(m20229094_000001_create_users::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[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: u32,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub duration: u32,
|
||||||
|
pub genres: Option<String>,
|
||||||
|
pub track: Option<i32>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl RelationTrait for Relation {
|
||||||
|
fn def(&self) -> RelationDef {
|
||||||
|
panic!("No RelationDef")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
|
@ -0,0 +1,6 @@
|
||||||
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod library;
|
||||||
|
pub mod users;
|
|
@ -0,0 +1,4 @@
|
||||||
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
|
pub use super::library::Entity as Library;
|
||||||
|
pub use super::users::Entity as Users;
|
|
@ -0,0 +1,24 @@
|
||||||
|
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl RelationTrait for Relation {
|
||||||
|
fn def(&self) -> RelationDef {
|
||||||
|
panic!("No RelationDef")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
|
@ -0,0 +1,203 @@
|
||||||
|
use crate::{
|
||||||
|
model::{library, users},
|
||||||
|
utils::path_from_hash,
|
||||||
|
};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequest, Path, RequestParts},
|
||||||
|
http::{
|
||||||
|
header::{self, HeaderName},
|
||||||
|
Request, StatusCode,
|
||||||
|
},
|
||||||
|
middleware::{self, Next},
|
||||||
|
response::Response,
|
||||||
|
routing::get,
|
||||||
|
Extension, Router,
|
||||||
|
};
|
||||||
|
use axum_auth::AuthBasic;
|
||||||
|
use miette::{miette, IntoDiagnostic};
|
||||||
|
use paris::success;
|
||||||
|
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set};
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
|
||||||
|
pub fn routes(db: DatabaseConnection) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_library).post(reindex))
|
||||||
|
.route("/:hash", get(get_stream))
|
||||||
|
.route("/:hash/cover", get(get_album_art))
|
||||||
|
.layer(
|
||||||
|
ServiceBuilder::new()
|
||||||
|
.layer(Extension(db))
|
||||||
|
.layer(middleware::from_fn(auth)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_library(
|
||||||
|
Extension(ref db): Extension<DatabaseConnection>,
|
||||||
|
) -> Result<Vec<u8>, StatusCode> {
|
||||||
|
let library = library::Entity::find()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let result = rmp_serde::to_vec(&library).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reindex(Extension(ref db): Extension<DatabaseConnection>) -> Result<(), StatusCode> {
|
||||||
|
super::fetching::reindex(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stream(
|
||||||
|
Path(hash): Path<u32>,
|
||||||
|
Extension(ref db): Extension<DatabaseConnection>,
|
||||||
|
) -> Result<([(HeaderName, String); 1], Vec<u8>), StatusCode> {
|
||||||
|
let path = path_from_hash(&db, hash)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let data = std::fs::read(&path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mime = mime_guess::from_path(&path)
|
||||||
|
.first()
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or("application/octet-stream".into());
|
||||||
|
|
||||||
|
Ok(([(header::CONTENT_TYPE, mime)], data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_album_art(
|
||||||
|
Path(hash): Path<u32>,
|
||||||
|
Extension(ref db): Extension<DatabaseConnection>,
|
||||||
|
) -> Result<([(HeaderName, String); 1], Vec<u8>), StatusCode> {
|
||||||
|
let path = path_from_hash(&db, hash)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let tagged_file = lofty::read_from_path(path, false).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let tags = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.unwrap_or(tagged_file.first_tag().ok_or(StatusCode::NOT_FOUND)?);
|
||||||
|
|
||||||
|
let picture = tags.pictures().get(0).ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[(header::CONTENT_TYPE, picture.mime_type().to_string())],
|
||||||
|
picture.data().to_vec(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> miette::Result<()> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| return miette!("Couldn't hash password: {}", err.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let user = users::ActiveModel {
|
||||||
|
name: Set(username.clone()),
|
||||||
|
password: Set(hash),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
users::Entity::insert(user)
|
||||||
|
.on_conflict(
|
||||||
|
sea_query::OnConflict::column(users::Column::Name)
|
||||||
|
.do_nothing()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
success!("User {username} added");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_user(db: &DatabaseConnection, username: String) -> miette::Result<()> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(users::Column::Name.eq(username.clone()))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
user.ok_or(miette!("User {} not found", username))?
|
||||||
|
.delete(db)
|
||||||
|
.await
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
success!("User {username} removed");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(password: String, hash: String) -> miette::Result<bool> {
|
||||||
|
let hash = PasswordHash::new(&hash)
|
||||||
|
.map_err(|err| return miette!("Couldn't parse password hash: {}", err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &hash)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
AuthBasic((username, password)): AuthBasic,
|
||||||
|
) -> Result<(), StatusCode> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(users::Column::Name.eq(username))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
// Compare the provided password with the password hash stored in the database
|
||||||
|
let authorized = verify_password(password.ok_or(StatusCode::UNAUTHORIZED)?, user.password)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if authorized {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth<B: std::marker::Send>(
|
||||||
|
req: Request<B>,
|
||||||
|
next: Next<B>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let mut req = RequestParts::new(req);
|
||||||
|
|
||||||
|
let auth = AuthBasic::from_request(&mut req).await.map_err(|e| e.0)?;
|
||||||
|
|
||||||
|
let db: &DatabaseConnection = req
|
||||||
|
.extensions()
|
||||||
|
.get()
|
||||||
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if let Err(error) = authenticate(db, auth.to_owned()).await {
|
||||||
|
Err(error)
|
||||||
|
} else {
|
||||||
|
let req = req
|
||||||
|
.try_into_request()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let res = next.run(req).await;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
use miette::{miette, IntoDiagnostic, Result};
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
use crate::{config::Config, migrator::Migrator, model::library};
|
||||||
|
use paris::success;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
|
||||||
|
/// If no files have been created in the current directory, the app is running for the first time
|
||||||
|
pub fn is_first_run() -> Result<bool> {
|
||||||
|
let path = std::env::current_dir()
|
||||||
|
.and_then(|v| Ok(v.join("settings.toml")))
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
Ok(!path.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the necessary files on first run
|
||||||
|
pub fn create_app_data() -> Result<()> {
|
||||||
|
let path = std::env::current_dir().into_diagnostic()?;
|
||||||
|
|
||||||
|
File::create(&path.join("eleanor-server.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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a file path from the audio hash
|
||||||
|
pub async fn path_from_hash(db: &sea_orm::DatabaseConnection, hash: u32) -> Result<String> {
|
||||||
|
let track = library::Entity::find()
|
||||||
|
.filter(library::Column::Hash.eq(hash))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.ok_or(miette!("Track not found"))?;
|
||||||
|
|
||||||
|
let path = format!("{}/{}", track.path, track.filename);
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
Loading…
Reference in New Issue