App features re-organization (#111)
This commit is contained in:
parent
866d82a16c
commit
34e0538562
63 changed files with 2334 additions and 2058 deletions
.gitignore
src
app
config.rsdb
ddns.rsindex
lastfm.rsmain.rsoptions.rsplaylist.rsservice
test.rsthumbnails.rsuser.rsvfs.rs
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -9,7 +9,7 @@ TestConfig.toml
|
|||
|
||||
# Runtime artifacts
|
||||
*.sqlite
|
||||
thumbnails
|
||||
/thumbnails
|
||||
|
||||
# Release process artifacts (usually runs on CI)
|
||||
release
|
||||
|
|
19
src/app/config/error.rs
Normal file
19
src/app/config/error.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Missing auth secret")]
|
||||
AuthSecretNotFound,
|
||||
#[error("Missing index sleep duration")]
|
||||
IndexSleepDurationNotFound,
|
||||
#[error("Missing index album art pattern")]
|
||||
IndexAlbumArtPatternNotFound,
|
||||
#[error("Index album art pattern is not a valid regex")]
|
||||
IndexAlbumArtPatternInvalid,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
Error::Unspecified
|
||||
}
|
||||
}
|
190
src/app/config/manager.rs
Normal file
190
src/app/config/manager.rs
Normal file
|
@ -0,0 +1,190 @@
|
|||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use std::time::Duration;
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::app::user;
|
||||
use crate::db::{ddns_config, misc_settings, mount_points, users, DB};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
pub db: DB,
|
||||
user_manager: user::Manager,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB, user_manager: user::Manager) -> Self {
|
||||
Self { db, user_manager }
|
||||
}
|
||||
|
||||
pub fn get_auth_secret(&self) -> Result<Vec<u8>, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(auth_secret)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::AuthSecretNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_index_sleep_duration(&self) -> Result<Duration, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_sleep_duration_seconds)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexSleepDurationNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.map(|s: i32| Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
pub fn get_index_album_art_pattern(&self) -> Result<Regex, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_album_art_pattern)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexAlbumArtPatternNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.and_then(|s: String| Regex::new(&s).map_err(|_| Error::IndexAlbumArtPatternInvalid))
|
||||
}
|
||||
|
||||
pub fn read(&self) -> anyhow::Result<Config> {
|
||||
use self::ddns_config::dsl::*;
|
||||
use self::misc_settings::dsl::*;
|
||||
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let mut config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: None,
|
||||
ydns: None,
|
||||
};
|
||||
|
||||
let (art_pattern, sleep_duration) = misc_settings
|
||||
.select((index_album_art_pattern, index_sleep_duration_seconds))
|
||||
.get_result(&connection)?;
|
||||
|
||||
config.album_art_pattern = Some(art_pattern);
|
||||
config.reindex_every_n_seconds = Some(sleep_duration);
|
||||
|
||||
let mount_dirs;
|
||||
{
|
||||
use self::mount_points::dsl::*;
|
||||
mount_dirs = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
config.mount_dirs = Some(mount_dirs);
|
||||
}
|
||||
|
||||
let found_users: Vec<(String, i32)> = users::table
|
||||
.select((users::columns::name, users::columns::admin))
|
||||
.get_results(&connection)?;
|
||||
config.users = Some(
|
||||
found_users
|
||||
.into_iter()
|
||||
.map(|(name, admin)| ConfigUser {
|
||||
name,
|
||||
password: "".to_owned(),
|
||||
admin: admin != 0,
|
||||
})
|
||||
.collect::<_>(),
|
||||
);
|
||||
|
||||
let ydns = ddns_config
|
||||
.select((host, username, password))
|
||||
.get_result(&connection)?;
|
||||
config.ydns = Some(ydns);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn amend(&self, new_config: &Config) -> anyhow::Result<()> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
if let Some(ref mount_dirs) = new_config.mount_dirs {
|
||||
diesel::delete(mount_points::table).execute(&connection)?;
|
||||
diesel::insert_into(mount_points::table)
|
||||
.values(mount_dirs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
}
|
||||
|
||||
if let Some(ref config_users) = new_config.users {
|
||||
let old_usernames: Vec<String> =
|
||||
users::table.select(users::name).get_results(&connection)?;
|
||||
|
||||
// Delete users that are not in new list
|
||||
let delete_usernames: Vec<String> = old_usernames
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|old_name| config_users.iter().find(|u| &u.name == old_name).is_none())
|
||||
.collect::<_>();
|
||||
diesel::delete(users::table.filter(users::name.eq_any(&delete_usernames)))
|
||||
.execute(&connection)?;
|
||||
|
||||
// Insert new users
|
||||
let insert_users: Vec<&ConfigUser> = config_users
|
||||
.iter()
|
||||
.filter(|u| {
|
||||
!u.name.is_empty()
|
||||
&& !u.password.is_empty()
|
||||
&& old_usernames
|
||||
.iter()
|
||||
.find(|old_name| *old_name == &u.name)
|
||||
.is_none()
|
||||
})
|
||||
.collect::<_>();
|
||||
for config_user in &insert_users {
|
||||
self.user_manager
|
||||
.create_user(&config_user.name, &config_user.password)?;
|
||||
}
|
||||
|
||||
// Update users
|
||||
for user in config_users.iter() {
|
||||
// Update password if provided
|
||||
if !user.password.is_empty() {
|
||||
self.user_manager.set_password(&user.name, &user.password)?;
|
||||
}
|
||||
|
||||
// Update admin rights
|
||||
diesel::update(users::table.filter(users::name.eq(&user.name)))
|
||||
.set(users::admin.eq(user.admin as i32))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sleep_duration) = new_config.reindex_every_n_seconds {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_sleep_duration_seconds.eq(sleep_duration as i32))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref album_art_pattern) = new_config.album_art_pattern {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_album_art_pattern.eq(album_art_pattern))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref ydns) = new_config.ydns {
|
||||
use self::ddns_config::dsl::*;
|
||||
diesel::update(ddns_config)
|
||||
.set((
|
||||
host.eq(ydns.host.clone()),
|
||||
username.eq(ydns.username.clone()),
|
||||
password.eq(ydns.password.clone()),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
69
src/app/config/mod.rs
Normal file
69
src/app/config/mod.rs
Normal file
|
@ -0,0 +1,69 @@
|
|||
use crate::app::{ddns, vfs};
|
||||
use core::ops::Deref;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Read;
|
||||
use std::path::{self, PathBuf};
|
||||
|
||||
mod error;
|
||||
mod manager;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub album_art_pattern: Option<String>,
|
||||
pub reindex_every_n_seconds: Option<i32>,
|
||||
pub mount_dirs: Option<Vec<vfs::MountPoint>>,
|
||||
pub users: Option<Vec<ConfigUser>>,
|
||||
pub ydns: Option<ddns::Config>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_path(path: &path::Path) -> anyhow::Result<Config> {
|
||||
let mut config_file = std::fs::File::open(path)?;
|
||||
let mut config_file_content = String::new();
|
||||
config_file.read_to_string(&mut config_file_content)?;
|
||||
let mut config = toml::de::from_str::<Config>(&config_file_content)?;
|
||||
config.clean_paths()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn clean_paths(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(ref mut mount_dirs) = self.mount_dirs {
|
||||
for mount_dir in mount_dirs {
|
||||
match Self::clean_path_string(&mount_dir.source).to_str() {
|
||||
Some(p) => mount_dir.source = p.to_owned(),
|
||||
_ => anyhow::bail!("Bad mount directory path"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clean_path_string(path_string: &str) -> PathBuf {
|
||||
let separator_regex = Regex::new(r"\\|/").unwrap();
|
||||
let mut correct_separator = String::new();
|
||||
correct_separator.push(path::MAIN_SEPARATOR);
|
||||
let path_string = separator_regex.replace_all(path_string, correct_separator.as_str());
|
||||
path::Path::new(path_string.deref()).iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable)]
|
||||
pub struct MiscSettings {
|
||||
id: i32,
|
||||
pub auth_secret: Vec<u8>,
|
||||
pub index_sleep_duration_seconds: i32,
|
||||
pub index_album_art_pattern: String,
|
||||
}
|
270
src/app/config/test.rs
Normal file
270
src/app/config/test.rs
Normal file
|
@ -0,0 +1,270 @@
|
|||
use diesel::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::app::{user, vfs};
|
||||
use crate::db::{users, DB};
|
||||
use crate::test_name;
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_test_db(name: &str) -> DB {
|
||||
let mut db_path = PathBuf::new();
|
||||
db_path.push("test-output");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
db_path.push(name);
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
DB::new(&db_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db, user_manager);
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: Some("file\\.png".into()),
|
||||
reindex_every_n_seconds: Some(123),
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
source: "C:\\Music".into(),
|
||||
name: "root".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: Some("🖼️\\.jpg".into()),
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
source: "/home/music".into(),
|
||||
name: "🎵📁".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "🐞🐞".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: Some(ddns::Config {
|
||||
host: "🐸🐸🐸.ydns.eu".into(),
|
||||
username: "kfr🐸g".into(),
|
||||
password: "tasty🐞".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let mut expected_config = new_config.clone();
|
||||
expected_config.reindex_every_n_seconds = initial_config.reindex_every_n_seconds;
|
||||
if let Some(ref mut users) = expected_config.users {
|
||||
users[0].password = "".into();
|
||||
}
|
||||
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
let db_config = config_manager.read().unwrap();
|
||||
assert_eq!(db_config, expected_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_preserve_password_hashes() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
let initial_hash: String;
|
||||
let new_hash: String;
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
initial_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![
|
||||
ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "tasty🐞".into(),
|
||||
admin: false,
|
||||
},
|
||||
ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
},
|
||||
]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
new_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(new_hash, initial_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_ignore_blank_users() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_admin() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: true,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 1);
|
||||
}
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_path_string() {
|
||||
let mut correct_path = path::PathBuf::new();
|
||||
if cfg!(target_os = "windows") {
|
||||
correct_path.push("C:\\");
|
||||
} else {
|
||||
correct_path.push("/usr");
|
||||
}
|
||||
correct_path.push("some");
|
||||
correct_path.push("path");
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:/some/path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some\path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some\path\"#));
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"C:\some\path\\\\"#)
|
||||
);
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some/path//"#));
|
||||
} else {
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"/usr/some/path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"/usr\some\path"#));
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some\path\"#)
|
||||
);
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some\path\\\\"#)
|
||||
);
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some/path//"#)
|
||||
);
|
||||
}
|
||||
}
|
11
src/app/ddns/config.rs
Normal file
11
src/app/ddns/config.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::ddns_config;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "ddns_config"]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
61
src/app/ddns/manager.rs
Normal file
61
src/app/ddns/manager.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use anyhow::*;
|
||||
use diesel::prelude::*;
|
||||
use log::{error, info};
|
||||
use reqwest;
|
||||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use super::*;
|
||||
use crate::db::DB;
|
||||
|
||||
const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/";
|
||||
|
||||
pub struct Manager {
|
||||
db: DB,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
fn update_my_ip(&self) -> Result<()> {
|
||||
let config = self.get_config()?;
|
||||
if config.host.is_empty() || config.username.is_empty() {
|
||||
info!("Skipping DDNS update because credentials are missing");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.host);
|
||||
let client = reqwest::ClientBuilder::new().build()?;
|
||||
let response = client
|
||||
.get(full_url.as_str())
|
||||
.basic_auth(config.username, Some(config.password))
|
||||
.send()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"DDNS update query failed with status code: {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_config(&self) -> Result<Config> {
|
||||
use crate::db::ddns_config::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
Ok(ddns_config
|
||||
.select((host, username, password))
|
||||
.get_result(&connection)?)
|
||||
}
|
||||
|
||||
pub fn run(&self) {
|
||||
loop {
|
||||
if let Err(e) = self.update_my_ip() {
|
||||
error!("Dynamic DNS update error: {:?}", e);
|
||||
}
|
||||
thread::sleep(time::Duration::from_secs(60 * 30));
|
||||
}
|
||||
}
|
||||
}
|
5
src/app/ddns/mod.rs
Normal file
5
src/app/ddns/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod config;
|
||||
mod manager;
|
||||
|
||||
pub use config::Config;
|
||||
pub use manager::Manager;
|
|
@ -1,13 +1,10 @@
|
|||
use anyhow::*;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use log::error;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::MiscSettings;
|
||||
use crate::db::{misc_settings, DB};
|
||||
use crate::vfs::VFS;
|
||||
use crate::app::{config, vfs};
|
||||
use crate::db::DB;
|
||||
|
||||
mod metadata;
|
||||
mod query;
|
||||
|
@ -23,14 +20,18 @@ pub use self::update::*;
|
|||
#[derive(Clone)]
|
||||
pub struct Index {
|
||||
db: DB,
|
||||
vfs_manager: vfs::Manager,
|
||||
config_manager: config::Manager,
|
||||
pending_reindex: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn new(db: DB) -> Self {
|
||||
pub fn new(db: DB, vfs_manager: vfs::Manager, config_manager: config::Manager) -> Self {
|
||||
let index = Self {
|
||||
pending_reindex: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
db,
|
||||
vfs_manager,
|
||||
config_manager,
|
||||
pending_reindex: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
};
|
||||
|
||||
let commands_index = index.clone();
|
||||
|
@ -65,7 +66,7 @@ impl Index {
|
|||
}
|
||||
*pending = false;
|
||||
}
|
||||
if let Err(e) = update(&self.db) {
|
||||
if let Err(e) = self.update() {
|
||||
error!("Error while updating index: {}", e);
|
||||
}
|
||||
}
|
||||
|
@ -74,21 +75,14 @@ impl Index {
|
|||
fn automatic_reindex(&self) {
|
||||
loop {
|
||||
self.trigger_reindex();
|
||||
let sleep_duration = {
|
||||
let connection = self.db.connect();
|
||||
connection
|
||||
.and_then(|c| {
|
||||
misc_settings::table
|
||||
.get_result(&c)
|
||||
.map_err(|e| Error::new(e))
|
||||
})
|
||||
.map(|s: MiscSettings| s.index_sleep_duration_seconds)
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Could not retrieve index sleep duration: {}", e);
|
||||
1800
|
||||
})
|
||||
};
|
||||
std::thread::sleep(time::Duration::from_secs(sleep_duration as u64));
|
||||
let sleep_duration = self
|
||||
.config_manager
|
||||
.get_index_sleep_duration()
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Could not retrieve index sleep duration: {}", e);
|
||||
Duration::from_secs(1800)
|
||||
});
|
||||
std::thread::sleep(sleep_duration);
|
||||
}
|
||||
}
|
||||
}
|
202
src/app/index/query.rs
Normal file
202
src/app/index/query.rs
Normal file
|
@ -0,0 +1,202 @@
|
|||
use anyhow::*;
|
||||
use diesel;
|
||||
use diesel::dsl::sql;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{directories, songs};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum QueryError {
|
||||
#[error("VFS path not found")]
|
||||
VFSPathNotFound,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for QueryError {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
QueryError::Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
no_arg_sql_function!(
|
||||
random,
|
||||
sql_types::Integer,
|
||||
"Represents the SQL RANDOM() function"
|
||||
);
|
||||
|
||||
impl Index {
|
||||
pub fn browse<P>(&self, virtual_path: P) -> Result<Vec<CollectionFile>, QueryError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let mut output = Vec::new();
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
if virtual_path.as_ref().components().count() == 0 {
|
||||
// Browse top-level
|
||||
let real_directories: Vec<Directory> = directories::table
|
||||
.filter(directories::parent.is_null())
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|d| d.virtualize(&vfs));
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
} else {
|
||||
// Browse sub-directory
|
||||
let real_path = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.map_err(|_| QueryError::VFSPathNotFound)?;
|
||||
let real_path_string = real_path.as_path().to_string_lossy().into_owned();
|
||||
|
||||
let real_directories: Vec<Directory> = directories::table
|
||||
.filter(directories::parent.eq(&real_path_string))
|
||||
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC"))
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|d| d.virtualize(&vfs));
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
|
||||
let real_songs: Vec<Song> = songs::table
|
||||
.filter(songs::parent.eq(&real_path_string))
|
||||
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC"))
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
|
||||
output.extend(virtual_songs.map(CollectionFile::Song));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn flatten<P>(&self, virtual_path: P) -> Result<Vec<Song>, QueryError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
use self::songs::dsl::*;
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let real_songs: Vec<Song> = if virtual_path.as_ref().parent() != None {
|
||||
let real_path = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.map_err(|_| QueryError::VFSPathNotFound)?;
|
||||
let song_path_filter = {
|
||||
let mut path_buf = real_path.clone();
|
||||
path_buf.push("%");
|
||||
path_buf.as_path().to_string_lossy().into_owned()
|
||||
};
|
||||
songs
|
||||
.filter(path.like(&song_path_filter))
|
||||
.order(path)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
} else {
|
||||
songs
|
||||
.order(path)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
};
|
||||
|
||||
let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
|
||||
Ok(virtual_songs.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn get_random_albums(&self, count: i64) -> Result<Vec<Directory>> {
|
||||
use self::directories::dsl::*;
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
let real_directories: Vec<Directory> = directories
|
||||
.filter(album.is_not_null())
|
||||
.limit(count)
|
||||
.order(random)
|
||||
.load(&connection)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|d| d.virtualize(&vfs));
|
||||
Ok(virtual_directories.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn get_recent_albums(&self, count: i64) -> Result<Vec<Directory>> {
|
||||
use self::directories::dsl::*;
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
let real_directories: Vec<Directory> = directories
|
||||
.filter(album.is_not_null())
|
||||
.order(date_added.desc())
|
||||
.limit(count)
|
||||
.load(&connection)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|d| d.virtualize(&vfs));
|
||||
Ok(virtual_directories.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str) -> Result<Vec<CollectionFile>> {
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
let like_test = format!("%{}%", query);
|
||||
let mut output = Vec::new();
|
||||
|
||||
// Find dirs with matching path and parent not matching
|
||||
{
|
||||
use self::directories::dsl::*;
|
||||
let real_directories: Vec<Directory> = directories
|
||||
.filter(path.like(&like_test))
|
||||
.filter(parent.not_like(&like_test))
|
||||
.load(&connection)?;
|
||||
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|d| d.virtualize(&vfs));
|
||||
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
}
|
||||
|
||||
// Find songs with matching title/album/artist and non-matching parent
|
||||
{
|
||||
use self::songs::dsl::*;
|
||||
let real_songs: Vec<Song> = songs
|
||||
.filter(
|
||||
path.like(&like_test)
|
||||
.or(title.like(&like_test))
|
||||
.or(album.like(&like_test))
|
||||
.or(artist.like(&like_test))
|
||||
.or(album_artist.like(&like_test)),
|
||||
)
|
||||
.filter(parent.not_like(&like_test))
|
||||
.load(&connection)?;
|
||||
|
||||
let virtual_songs = real_songs.into_iter().filter_map(|d| d.virtualize(&vfs));
|
||||
|
||||
output.extend(virtual_songs.map(CollectionFile::Song));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn get_song(&self, virtual_path: &Path) -> Result<Song> {
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let real_path = vfs.virtual_to_real(virtual_path)?;
|
||||
let real_path_string = real_path.as_path().to_string_lossy();
|
||||
|
||||
use self::songs::dsl::*;
|
||||
let real_song: Song = songs
|
||||
.filter(path.eq(real_path_string))
|
||||
.get_result(&connection)?;
|
||||
|
||||
match real_song.virtualize(&vfs) {
|
||||
Some(s) => Ok(s),
|
||||
_ => bail!("Missing VFS mapping"),
|
||||
}
|
||||
}
|
||||
}
|
210
src/app/index/test.rs
Normal file
210
src/app/index/test.rs
Normal file
|
@ -0,0 +1,210 @@
|
|||
use diesel::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
use crate::app::{config, user, vfs};
|
||||
use crate::db::{self, directories, songs};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_populate() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
index.update().unwrap(); // Validates that subsequent updates don't run into conflicts
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let all_directories: Vec<Directory> = directories::table.load(&connection).unwrap();
|
||||
let all_songs: Vec<Song> = songs::table.load(&connection).unwrap();
|
||||
assert_eq!(all_directories.len(), 6);
|
||||
assert_eq!(all_songs.len(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata() {
|
||||
let target: PathBuf = ["test-data", "small-collection", "Tobokegao", "Picnic"]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let mut song_path = target.clone();
|
||||
song_path.push("05 - シャーベット (Sherbet).mp3");
|
||||
|
||||
let mut artwork_path = target.clone();
|
||||
artwork_path.push("Folder.png");
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let songs: Vec<Song> = songs::table
|
||||
.filter(songs::title.eq("シャーベット (Sherbet)"))
|
||||
.load(&connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(songs.len(), 1);
|
||||
let song = &songs[0];
|
||||
assert_eq!(song.path, song_path.to_string_lossy().as_ref());
|
||||
assert_eq!(song.track_number, Some(5));
|
||||
assert_eq!(song.disc_number, None);
|
||||
assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned()));
|
||||
assert_eq!(song.artist, Some("Tobokegao".to_owned()));
|
||||
assert_eq!(song.album_artist, None);
|
||||
assert_eq!(song.album, Some("Picnic".to_owned()));
|
||||
assert_eq!(song.year, Some(2016));
|
||||
assert_eq!(
|
||||
song.artwork,
|
||||
Some(artwork_path.to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedded_artwork() {
|
||||
let song_path: PathBuf = [
|
||||
"test-data",
|
||||
"small-collection",
|
||||
"Tobokegao",
|
||||
"Picnic",
|
||||
"07 - なぜ (Why).mp3",
|
||||
]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let songs: Vec<Song> = songs::table
|
||||
.filter(songs::title.eq("なぜ (Why?)"))
|
||||
.load(&connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(songs.len(), 1);
|
||||
let song = &songs[0];
|
||||
assert_eq!(song.artwork, Some(song_path.to_string_lossy().into_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browse_top_level() {
|
||||
let mut root_path = PathBuf::new();
|
||||
root_path.push("root");
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.browse(Path::new("")).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match results[0] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, root_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browse() {
|
||||
let khemmis_path: PathBuf = ["root", "Khemmis"].iter().collect();
|
||||
let tobokegao_path: PathBuf = ["root", "Tobokegao"].iter().collect();
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.browse(Path::new("root")).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
match results[0] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, khemmis_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
match results[1] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, tobokegao_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
// Flatten all
|
||||
let results = index.flatten(Path::new("root")).unwrap();
|
||||
assert_eq!(results.len(), 13);
|
||||
assert_eq!(results[0].title, Some("Above The Water".to_owned()));
|
||||
|
||||
// Flatten a directory
|
||||
let path: PathBuf = ["root", "Tobokegao"].iter().collect();
|
||||
let results = index.flatten(&path).unwrap();
|
||||
assert_eq!(results.len(), 8);
|
||||
|
||||
// Flatten a directory that is a prefix of another directory (Picnic Remixes)
|
||||
let path: PathBuf = ["root", "Tobokegao", "Picnic"].iter().collect();
|
||||
let results = index.flatten(&path).unwrap();
|
||||
assert_eq!(results.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.get_random_albums(1).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recent() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.get_recent_albums(2).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].date_added >= results[1].date_added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_song() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
index.update().unwrap();
|
||||
|
||||
let song_path: PathBuf = ["root", "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let song = index.get_song(&song_path).unwrap();
|
||||
assert_eq!(song.title.unwrap(), "Candlelight");
|
||||
}
|
|
@ -1,7 +1,15 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::vfs::VFS;
|
||||
use crate::db::songs;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CollectionFile {
|
||||
Directory(Directory),
|
||||
Song(Song),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Queryable, QueryableByName, Serialize, Deserialize)]
|
||||
#[table_name = "songs"]
|
||||
pub struct Song {
|
||||
|
@ -21,6 +29,22 @@ pub struct Song {
|
|||
pub duration: Option<i32>,
|
||||
}
|
||||
|
||||
impl Song {
|
||||
pub fn virtualize(mut self, vfs: &VFS) -> Option<Song> {
|
||||
self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
|
||||
Ok(p) => p.to_string_lossy().into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(artwork_path) = self.artwork {
|
||||
self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
|
||||
Ok(p) => Some(p.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Queryable, Serialize, Deserialize)]
|
||||
pub struct Directory {
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
|
@ -35,8 +59,18 @@ pub struct Directory {
|
|||
pub date_added: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CollectionFile {
|
||||
Directory(Directory),
|
||||
Song(Song),
|
||||
impl Directory {
|
||||
pub fn virtualize(mut self, vfs: &VFS) -> Option<Directory> {
|
||||
self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
|
||||
Ok(p) => p.to_string_lossy().into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(artwork_path) = self.artwork {
|
||||
self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
|
||||
Ok(p) => Some(p.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
|
@ -4,22 +4,23 @@ use diesel::prelude::*;
|
|||
use rayon::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::vfs;
|
||||
use crate::db::{directories, songs, DB};
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
const INDEX_BUILDING_CLEAN_BUFFER_SIZE: usize = 500; // Deletions in each transaction
|
||||
|
||||
pub struct Cleaner {
|
||||
db: DB,
|
||||
vfs_manager: vfs::Manager,
|
||||
}
|
||||
|
||||
impl Cleaner {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
pub fn new(db: DB, vfs_manager: vfs::Manager) -> Self {
|
||||
Self { db, vfs_manager }
|
||||
}
|
||||
|
||||
pub fn clean(&self) -> Result<()> {
|
||||
let vfs = self.db.get_vfs()?;
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
|
||||
let all_directories: Vec<String> = {
|
||||
let connection = self.db.connect()?;
|
|
@ -1,8 +1,9 @@
|
|||
use crate::index::update::{inserter, traverser};
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use log::error;
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub struct Collector {
|
||||
receiver: Receiver<traverser::Directory>,
|
||||
sender: Sender<inserter::Item>,
|
65
src/app/index/update/mod.rs
Normal file
65
src/app/index/update/mod.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use anyhow::*;
|
||||
use log::{error, info};
|
||||
use std::time;
|
||||
|
||||
mod cleaner;
|
||||
mod collector;
|
||||
mod inserter;
|
||||
mod traverser;
|
||||
|
||||
use super::*;
|
||||
use cleaner::Cleaner;
|
||||
use collector::Collector;
|
||||
use inserter::Inserter;
|
||||
use traverser::Traverser;
|
||||
|
||||
impl Index {
|
||||
pub fn update(&self) -> Result<()> {
|
||||
let start = time::Instant::now();
|
||||
info!("Beginning library index update");
|
||||
|
||||
let album_art_pattern = self.config_manager.get_index_album_art_pattern()?;
|
||||
|
||||
let cleaner = Cleaner::new(self.db.clone(), self.vfs_manager.clone());
|
||||
cleaner.clean()?;
|
||||
|
||||
let (insert_sender, insert_receiver) = crossbeam_channel::unbounded();
|
||||
let inserter_db = self.db.clone();
|
||||
let insertion_thread = std::thread::spawn(move || {
|
||||
let mut inserter = Inserter::new(inserter_db, insert_receiver);
|
||||
inserter.insert();
|
||||
});
|
||||
|
||||
let (collect_sender, collect_receiver) = crossbeam_channel::unbounded();
|
||||
let collector_thread = std::thread::spawn(move || {
|
||||
let collector = Collector::new(collect_receiver, insert_sender, album_art_pattern);
|
||||
collector.collect();
|
||||
});
|
||||
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let traverser_thread = std::thread::spawn(move || {
|
||||
let mount_points = vfs.get_mount_points();
|
||||
let traverser = Traverser::new(collect_sender);
|
||||
traverser.traverse(mount_points.values().map(|p| p.clone()).collect());
|
||||
});
|
||||
|
||||
if let Err(e) = traverser_thread.join() {
|
||||
error!("Error joining on traverser thread: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = collector_thread.join() {
|
||||
error!("Error joining on collector thread: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = insertion_thread.join() {
|
||||
error!("Error joining on inserter thread: {:?}", e);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Library index update took {} seconds",
|
||||
start.elapsed().as_millis() as f32 / 1000.0
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ use std::sync::Arc;
|
|||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::index::metadata::{self, SongTags};
|
||||
use crate::app::index::metadata::{self, SongTags};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Song {
|
93
src/app/lastfm/manager.rs
Normal file
93
src/app/lastfm/manager.rs
Normal file
|
@ -0,0 +1,93 @@
|
|||
use anyhow::*;
|
||||
use rustfm_scrobble::{Scrobble, Scrobbler};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::{index::Index, user};
|
||||
|
||||
const LASTFM_API_KEY: &str = "02b96c939a2b451c31dfd67add1f696e";
|
||||
const LASTFM_API_SECRET: &str = "0f25a80ceef4b470b5cb97d99d4b3420";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionName {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionKey {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionSubscriber {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSession {
|
||||
pub name: AuthResponseSessionName,
|
||||
pub key: AuthResponseSessionKey,
|
||||
pub subscriber: AuthResponseSessionSubscriber,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponse {
|
||||
pub status: String,
|
||||
pub session: AuthResponseSession,
|
||||
}
|
||||
|
||||
pub struct Manager {
|
||||
index: Index,
|
||||
user_manager: user::Manager,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(index: Index, user_manager: user::Manager) -> Self {
|
||||
Self {
|
||||
index,
|
||||
user_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn link(&self, username: &str, token: &str) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let auth_response = scrobbler.authenticate_with_token(token)?;
|
||||
|
||||
self.user_manager
|
||||
.lastfm_link(username, &auth_response.name, &auth_response.key)
|
||||
}
|
||||
|
||||
pub fn unlink(&self, username: &str) -> Result<()> {
|
||||
self.user_manager.lastfm_unlink(username)
|
||||
}
|
||||
|
||||
pub fn scrobble(&self, username: &str, track: &Path) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = self.scrobble_from_path(track)?;
|
||||
let auth_token = self.user_manager.get_lastfm_session_key(username)?;
|
||||
scrobbler.authenticate_with_session_key(&auth_token);
|
||||
scrobbler.scrobble(&scrobble)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn now_playing(&self, username: &str, track: &Path) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = self.scrobble_from_path(track)?;
|
||||
let auth_token = self.user_manager.get_lastfm_session_key(username)?;
|
||||
scrobbler.authenticate_with_session_key(&auth_token);
|
||||
scrobbler.now_playing(&scrobble)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble> {
|
||||
let song = self.index.get_song(track)?;
|
||||
Ok(Scrobble::new(
|
||||
song.artist.as_deref().unwrap_or(""),
|
||||
song.title.as_deref().unwrap_or(""),
|
||||
song.album.as_deref().unwrap_or(""),
|
||||
))
|
||||
}
|
||||
}
|
3
src/app/lastfm/mod.rs
Normal file
3
src/app/lastfm/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
mod manager;
|
||||
|
||||
pub use manager::*;
|
8
src/app/mod.rs
Normal file
8
src/app/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
pub mod config;
|
||||
pub mod ddns;
|
||||
pub mod index;
|
||||
pub mod lastfm;
|
||||
pub mod playlist;
|
||||
pub mod thumbnail;
|
||||
pub mod user;
|
||||
pub mod vfs;
|
15
src/app/playlist/error.rs
Normal file
15
src/app/playlist/error.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
#[error("Playlist not found")]
|
||||
PlaylistNotFound,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
Error::Unspecified
|
||||
}
|
||||
}
|
246
src/app/playlist/manager.rs
Normal file
246
src/app/playlist/manager.rs
Normal file
|
@ -0,0 +1,246 @@
|
|||
use anyhow::Result;
|
||||
use core::clone::Clone;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types;
|
||||
use diesel::BelongingToDsl;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::app::index::Song;
|
||||
use crate::app::vfs;
|
||||
use crate::db::{playlist_songs, playlists, users, DB};
|
||||
|
||||
pub struct Manager {
|
||||
db: DB,
|
||||
vfs_manager: vfs::Manager,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB, vfs_manager: vfs::Manager) -> Self {
|
||||
Self { db, vfs_manager }
|
||||
}
|
||||
|
||||
pub fn list_playlists(&self, owner: &str) -> Result<Vec<String>, Error> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(Error::UserNotFound)?
|
||||
};
|
||||
|
||||
{
|
||||
use self::playlists::dsl::*;
|
||||
let found_playlists: Vec<String> = Playlist::belonging_to(&user)
|
||||
.select(name)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
Ok(found_playlists)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_playlist(
|
||||
&self,
|
||||
playlist_name: &str,
|
||||
owner: &str,
|
||||
content: &[String],
|
||||
) -> Result<(), Error> {
|
||||
let new_playlist: NewPlaylist;
|
||||
let playlist: Playlist;
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
|
||||
{
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
// Find owner
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(Error::UserNotFound)?
|
||||
};
|
||||
|
||||
// Create playlist
|
||||
new_playlist = NewPlaylist {
|
||||
name: playlist_name.into(),
|
||||
owner: user.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(playlists::table)
|
||||
.values(&new_playlist)
|
||||
.execute(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
|
||||
playlist = {
|
||||
use self::playlists::dsl::*;
|
||||
playlists
|
||||
.select((id, owner))
|
||||
.filter(name.eq(playlist_name).and(owner.eq(user.id)))
|
||||
.get_result(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_songs: Vec<NewPlaylistSong> = Vec::new();
|
||||
new_songs.reserve(content.len());
|
||||
|
||||
for (i, path) in content.iter().enumerate() {
|
||||
let virtual_path = Path::new(&path);
|
||||
if let Some(real_path) = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| s.to_owned()))
|
||||
{
|
||||
new_songs.push(NewPlaylistSong {
|
||||
playlist: playlist.id,
|
||||
path: real_path,
|
||||
ordering: i as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let connection = self.db.connect()?;
|
||||
connection
|
||||
.transaction::<_, diesel::result::Error, _>(|| {
|
||||
// Delete old content (if any)
|
||||
let old_songs = PlaylistSong::belonging_to(&playlist);
|
||||
diesel::delete(old_songs).execute(&connection)?;
|
||||
|
||||
// Insert content
|
||||
diesel::insert_into(playlist_songs::table)
|
||||
.values(&new_songs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
Ok(())
|
||||
})
|
||||
.map_err(anyhow::Error::new)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_playlist(&self, playlist_name: &str, owner: &str) -> Result<Vec<Song>, Error> {
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let songs: Vec<Song>;
|
||||
|
||||
{
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
// Find owner
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(Error::UserNotFound)?
|
||||
};
|
||||
|
||||
// Find playlist
|
||||
let playlist: Playlist = {
|
||||
use self::playlists::dsl::*;
|
||||
playlists
|
||||
.select((id, owner))
|
||||
.filter(name.eq(playlist_name).and(owner.eq(user.id)))
|
||||
.get_result(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(Error::PlaylistNotFound)?
|
||||
};
|
||||
|
||||
// Select songs. Not using Diesel because we need to LEFT JOIN using a custom column
|
||||
let query = diesel::sql_query(
|
||||
r#"
|
||||
SELECT s.id, s.path, s.parent, s.track_number, s.disc_number, s.title, s.artist, s.album_artist, s.year, s.album, s.artwork, s.duration
|
||||
FROM playlist_songs ps
|
||||
LEFT JOIN songs s ON ps.path = s.path
|
||||
WHERE ps.playlist = ?
|
||||
ORDER BY ps.ordering
|
||||
"#,
|
||||
);
|
||||
let query = query.clone().bind::<sql_types::Integer, _>(playlist.id);
|
||||
songs = query.get_results(&connection).map_err(anyhow::Error::new)?;
|
||||
}
|
||||
|
||||
// Map real path to virtual paths
|
||||
let virtual_songs = songs
|
||||
.into_iter()
|
||||
.filter_map(|s| s.virtualize(&vfs))
|
||||
.collect();
|
||||
|
||||
Ok(virtual_songs)
|
||||
}
|
||||
|
||||
pub fn delete_playlist(&self, playlist_name: &str, owner: &str) -> Result<(), Error> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(Error::UserNotFound)?
|
||||
};
|
||||
|
||||
{
|
||||
use self::playlists::dsl::*;
|
||||
let q = Playlist::belonging_to(&user).filter(name.eq(playlist_name));
|
||||
match diesel::delete(q)
|
||||
.execute(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
{
|
||||
0 => Err(Error::PlaylistNotFound),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[belongs_to(User, foreign_key = "owner")]
|
||||
struct Playlist {
|
||||
id: i32,
|
||||
owner: i32,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[belongs_to(Playlist, foreign_key = "playlist")]
|
||||
struct PlaylistSong {
|
||||
id: i32,
|
||||
playlist: i32,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "playlists"]
|
||||
struct NewPlaylist {
|
||||
name: String,
|
||||
owner: i32,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "playlist_songs"]
|
||||
struct NewPlaylistSong {
|
||||
playlist: i32,
|
||||
path: String,
|
||||
ordering: i32,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable)]
|
||||
struct User {
|
||||
id: i32,
|
||||
}
|
7
src/app/playlist/mod.rs
Normal file
7
src/app/playlist/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
mod error;
|
||||
mod manager;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
100
src/app/playlist/test.rs
Normal file
100
src/app/playlist/test.rs
Normal file
|
@ -0,0 +1,100 @@
|
|||
use core::clone::Clone;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
use crate::app::{config, index::Index, user, vfs};
|
||||
use crate::db;
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_create_playlist() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let manager = Manager::new(db, vfs_manager);
|
||||
|
||||
let found_playlists = manager.list_playlists("test_user").unwrap();
|
||||
assert!(found_playlists.is_empty());
|
||||
|
||||
manager
|
||||
.save_playlist("chill_and_grill", "test_user", &Vec::new())
|
||||
.unwrap();
|
||||
let found_playlists = manager.list_playlists("test_user").unwrap();
|
||||
assert_eq!(found_playlists.len(), 1);
|
||||
assert_eq!(found_playlists[0], "chill_and_grill");
|
||||
|
||||
let found_playlists = manager.list_playlists("someone_else");
|
||||
assert!(found_playlists.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_playlist() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let manager = Manager::new(db, vfs_manager);
|
||||
|
||||
let playlist_content = Vec::new();
|
||||
|
||||
manager
|
||||
.save_playlist("chill_and_grill", "test_user", &playlist_content)
|
||||
.unwrap();
|
||||
manager
|
||||
.save_playlist("mellow_bungalow", "test_user", &playlist_content)
|
||||
.unwrap();
|
||||
let found_playlists = manager.list_playlists("test_user").unwrap();
|
||||
assert_eq!(found_playlists.len(), 2);
|
||||
|
||||
manager
|
||||
.delete_playlist("chill_and_grill", "test_user")
|
||||
.unwrap();
|
||||
let found_playlists = manager.list_playlists("test_user").unwrap();
|
||||
assert_eq!(found_playlists.len(), 1);
|
||||
assert_eq!(found_playlists[0], "mellow_bungalow");
|
||||
|
||||
let delete_result = manager.delete_playlist("mellow_bungalow", "someone_else");
|
||||
assert!(delete_result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_playlist() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), config_manager);
|
||||
let manager = Manager::new(db, vfs_manager);
|
||||
|
||||
index.update().unwrap();
|
||||
|
||||
let mut playlist_content: Vec<String> = index
|
||||
.flatten(Path::new("root"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.path)
|
||||
.collect();
|
||||
assert_eq!(playlist_content.len(), 13);
|
||||
|
||||
let first_song = playlist_content[0].clone();
|
||||
playlist_content.push(first_song);
|
||||
assert_eq!(playlist_content.len(), 14);
|
||||
|
||||
manager
|
||||
.save_playlist("all_the_music", "test_user", &playlist_content)
|
||||
.unwrap();
|
||||
|
||||
let songs = manager.read_playlist("all_the_music", "test_user").unwrap();
|
||||
assert_eq!(songs.len(), 14);
|
||||
assert_eq!(songs[0].title, Some("Above The Water".to_owned()));
|
||||
assert_eq!(songs[13].title, Some("Above The Water".to_owned()));
|
||||
|
||||
let first_song_path: PathBuf = ["root", "Khemmis", "Hunted", "01 - Above The Water.mp3"]
|
||||
.iter()
|
||||
.collect();
|
||||
assert_eq!(songs[0].path, first_song_path.to_str().unwrap());
|
||||
|
||||
// Save again to verify that we don't dupe the content
|
||||
manager
|
||||
.save_playlist("all_the_music", "test_user", &playlist_content)
|
||||
.unwrap();
|
||||
let songs = manager.read_playlist("all_the_music", "test_user").unwrap();
|
||||
assert_eq!(songs.len(), 14);
|
||||
}
|
40
src/app/thumbnail/generate.rs
Normal file
40
src/app/thumbnail/generate.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use anyhow::*;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer};
|
||||
use std::cmp;
|
||||
use std::path::*;
|
||||
|
||||
use crate::app::thumbnail::{read, Options};
|
||||
|
||||
pub fn generate_thumbnail(image_path: &Path, thumbnailoptions: &Options) -> Result<DynamicImage> {
|
||||
let source_image = read(image_path)?;
|
||||
let (source_width, source_height) = source_image.dimensions();
|
||||
let largest_dimension = cmp::max(source_width, source_height);
|
||||
let out_dimension = cmp::min(thumbnailoptions.max_dimension, largest_dimension);
|
||||
|
||||
let source_aspect_ratio: f32 = source_width as f32 / source_height as f32;
|
||||
let is_almost_square = source_aspect_ratio > 0.8 && source_aspect_ratio < 1.2;
|
||||
|
||||
let mut final_image;
|
||||
if is_almost_square && thumbnailoptions.resize_if_almost_square {
|
||||
final_image = source_image.resize_exact(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
} else if thumbnailoptions.pad_to_square {
|
||||
let scaled_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
let (scaled_width, scaled_height) = scaled_image.dimensions();
|
||||
let background = image::Rgb([255, 255 as u8, 255 as u8]);
|
||||
final_image = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(
|
||||
out_dimension,
|
||||
out_dimension,
|
||||
background,
|
||||
));
|
||||
final_image.copy_from(
|
||||
&scaled_image,
|
||||
(out_dimension - scaled_width) / 2,
|
||||
(out_dimension - scaled_height) / 2,
|
||||
)?;
|
||||
} else {
|
||||
final_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
}
|
||||
|
||||
Ok(final_image)
|
||||
}
|
65
src/app/thumbnail/manager.rs
Normal file
65
src/app/thumbnail/manager.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use anyhow::*;
|
||||
use image::ImageOutputFormat;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs::{self, File};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::thumbnail::*;
|
||||
|
||||
pub struct Manager {
|
||||
thumbnails_dir_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(thumbnails_dir_path: PathBuf) -> Self {
|
||||
Self {
|
||||
thumbnails_dir_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_directory(&self) -> &Path {
|
||||
&self.thumbnails_dir_path
|
||||
}
|
||||
|
||||
pub fn get_thumbnail(&self, image_path: &Path, thumbnailoptions: &Options) -> Result<PathBuf> {
|
||||
match self.retrieve_thumbnail(image_path, thumbnailoptions) {
|
||||
Some(path) => Ok(path),
|
||||
None => self.create_thumbnail(image_path, thumbnailoptions),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_thumbnail_path(&self, image_path: &Path, thumbnailoptions: &Options) -> PathBuf {
|
||||
let hash = Manager::hash(image_path, thumbnailoptions);
|
||||
let mut thumbnail_path = self.thumbnails_dir_path.clone();
|
||||
thumbnail_path.push(format!("{}.jpg", hash.to_string()));
|
||||
thumbnail_path
|
||||
}
|
||||
|
||||
fn retrieve_thumbnail(&self, image_path: &Path, thumbnailoptions: &Options) -> Option<PathBuf> {
|
||||
let path = self.get_thumbnail_path(image_path, thumbnailoptions);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn create_thumbnail(&self, image_path: &Path, thumbnailoptions: &Options) -> Result<PathBuf> {
|
||||
let thumbnail = generate_thumbnail(image_path, thumbnailoptions)?;
|
||||
let quality = 80;
|
||||
|
||||
fs::create_dir_all(&self.thumbnails_dir_path)?;
|
||||
let path = self.get_thumbnail_path(image_path, thumbnailoptions);
|
||||
let mut out_file = File::create(&path)?;
|
||||
thumbnail.write_to(&mut out_file, ImageOutputFormat::Jpeg(quality))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn hash(path: &Path, thumbnailoptions: &Options) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
thumbnailoptions.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
9
src/app/thumbnail/mod.rs
Normal file
9
src/app/thumbnail/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
mod generate;
|
||||
mod manager;
|
||||
mod options;
|
||||
mod read;
|
||||
|
||||
pub use generate::*;
|
||||
pub use manager::*;
|
||||
pub use options::*;
|
||||
pub use read::*;
|
16
src/app/thumbnail/options.rs
Normal file
16
src/app/thumbnail/options.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#[derive(Debug, Hash)]
|
||||
pub struct Options {
|
||||
pub max_dimension: u32,
|
||||
pub resize_if_almost_square: bool,
|
||||
pub pad_to_square: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_dimension: 400,
|
||||
resize_if_almost_square: true,
|
||||
pad_to_square: true,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,7 +19,9 @@ pub fn read(image_path: &Path) -> Result<DynamicImage> {
|
|||
}
|
||||
|
||||
fn read_ape(_: &Path) -> Result<DynamicImage> {
|
||||
Err(crate::Error::msg("Embedded ape artworks not yet supported"))
|
||||
Err(crate::Error::msg(
|
||||
"Embedded images are not supported in APE files",
|
||||
))
|
||||
}
|
||||
|
||||
fn read_flac(path: &Path) -> Result<DynamicImage> {
|
||||
|
@ -62,13 +64,13 @@ fn read_mp4(path: &Path) -> Result<DynamicImage> {
|
|||
|
||||
fn read_vorbis(_: &Path) -> Result<DynamicImage> {
|
||||
Err(crate::Error::msg(
|
||||
"Embedded vorbis artworks are not yet supported",
|
||||
"Embedded images are not supported in Vorbis files",
|
||||
))
|
||||
}
|
||||
|
||||
fn read_opus(_: &Path) -> Result<DynamicImage> {
|
||||
Err(crate::Error::msg(
|
||||
"Embedded opus artworks are not yet supported",
|
||||
"Embedded images are not supported in Opus files",
|
||||
))
|
||||
}
|
||||
|
13
src/app/user/error.rs
Normal file
13
src/app/user/error.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Cannot use empty password")]
|
||||
EmptyPassword,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
Error::Unspecified
|
||||
}
|
||||
}
|
145
src/app/user/manager.rs
Normal file
145
src/app/user/manager.rs
Normal file
|
@ -0,0 +1,145 @@
|
|||
use anyhow::anyhow;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::db::DB;
|
||||
|
||||
const HASH_ITERATIONS: u32 = 10000;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
pub db: DB,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub fn create_user(&self, username: &str, password: &str) -> Result<(), Error> {
|
||||
if password.is_empty() {
|
||||
return Err(Error::EmptyPassword);
|
||||
}
|
||||
let password_hash = hash_password(password)?;
|
||||
let connection = self.db.connect()?;
|
||||
let new_user = User {
|
||||
name: username.to_owned(),
|
||||
password_hash,
|
||||
admin: 0,
|
||||
};
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
|
||||
let password_hash = hash_password(password)?;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users::table.filter(users::name.eq(username)))
|
||||
.set(users::password_hash.eq(password_hash))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auth(&self, username: &str, password: &str) -> anyhow::Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
match users
|
||||
.select(password_hash)
|
||||
.filter(name.eq(username))
|
||||
.get_result(&connection)
|
||||
{
|
||||
Err(diesel::result::Error::NotFound) => Ok(false),
|
||||
Ok(hash) => {
|
||||
let hash: String = hash;
|
||||
Ok(verify_password(&hash, password))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count(&self) -> anyhow::Result<i64> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let count = users.count().get_result(&connection)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let results: Vec<String> = users
|
||||
.select(name)
|
||||
.filter(name.eq(username))
|
||||
.get_results(&connection)?;
|
||||
Ok(results.len() > 0)
|
||||
}
|
||||
|
||||
pub fn is_admin(&self, username: &str) -> anyhow::Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let is_admin: i32 = users
|
||||
.filter(name.eq(username))
|
||||
.select(admin)
|
||||
.get_result(&connection)?;
|
||||
Ok(is_admin != 0)
|
||||
}
|
||||
|
||||
pub fn lastfm_link(
|
||||
&self,
|
||||
username: &str,
|
||||
lastfm_login: &str,
|
||||
session_key: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((
|
||||
lastfm_username.eq(lastfm_login),
|
||||
lastfm_session_key.eq(session_key),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_lastfm_session_key(&self, username: &str) -> anyhow::Result<String> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let token = users
|
||||
.filter(name.eq(username))
|
||||
.select(lastfm_session_key)
|
||||
.get_result(&connection)?;
|
||||
match token {
|
||||
Some(t) => Ok(t),
|
||||
_ => Err(anyhow!("Missing LastFM credentials")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_lastfm_linked(&self, username: &str) -> bool {
|
||||
self.get_lastfm_session_key(username).is_ok()
|
||||
}
|
||||
|
||||
pub fn lastfm_unlink(&self, username: &str) -> anyhow::Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((lastfm_session_key.eq(""), lastfm_username.eq("")))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
match pbkdf2::pbkdf2_simple(password, HASH_ITERATIONS) {
|
||||
Ok(hash) => Ok(hash),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(password_hash: &str, attempted_password: &str) -> bool {
|
||||
pbkdf2::pbkdf2_check(attempted_password, password_hash).is_ok()
|
||||
}
|
19
src/app/user/mod.rs
Normal file
19
src/app/user/mod.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use crate::db::users;
|
||||
|
||||
mod error;
|
||||
mod manager;
|
||||
mod preferences;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
pub use preferences::*;
|
||||
|
||||
#[derive(Debug, Insertable, Queryable)]
|
||||
#[table_name = "users"]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub admin: i32,
|
||||
}
|
41
src/app/user/preferences.rs
Normal file
41
src/app/user/preferences.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use anyhow::Result;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub lastfm_username: Option<String>,
|
||||
pub web_theme_base: Option<String>,
|
||||
pub web_theme_accent: Option<String>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn read_preferences(&self, username: &str) -> Result<Preferences> {
|
||||
use self::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let (theme_base, theme_accent, read_lastfm_username) = users
|
||||
.select((web_theme_base, web_theme_accent, lastfm_username))
|
||||
.filter(name.eq(username))
|
||||
.get_result(&connection)?;
|
||||
Ok(Preferences {
|
||||
web_theme_base: theme_base,
|
||||
web_theme_accent: theme_accent,
|
||||
lastfm_username: read_lastfm_username,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_preferences(&self, username: &str, preferences: &Preferences) -> Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((
|
||||
web_theme_base.eq(&preferences.web_theme_base),
|
||||
web_theme_accent.eq(&preferences.web_theme_accent),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
24
src/app/user/test.rs
Normal file
24
src/app/user/test.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use super::*;
|
||||
use crate::db;
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_preferences_read_write() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let manager = Manager::new(db);
|
||||
|
||||
let new_preferences = Preferences {
|
||||
web_theme_base: Some("very-dark-theme".to_owned()),
|
||||
web_theme_accent: Some("#FF0000".to_owned()),
|
||||
lastfm_username: None,
|
||||
};
|
||||
|
||||
manager.create_user("Walter", "super_secret!").unwrap();
|
||||
|
||||
manager
|
||||
.write_preferences("Walter", &new_preferences)
|
||||
.unwrap();
|
||||
|
||||
let read_preferences = manager.read_preferences("Walter").unwrap();
|
||||
assert_eq!(new_preferences, read_preferences);
|
||||
}
|
31
src/app/vfs/manager.rs
Normal file
31
src/app/vfs/manager.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use anyhow::*;
|
||||
use diesel::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::db::mount_points;
|
||||
use crate::db::DB;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
db: DB,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub fn get_vfs(&self) -> Result<VFS> {
|
||||
use self::mount_points::dsl::*;
|
||||
let mut vfs = VFS::new();
|
||||
let connection = self.db.connect()?;
|
||||
let points: Vec<MountPoint> = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
for point in points {
|
||||
vfs.mount(&Path::new(&point.source), &point.name)?;
|
||||
}
|
||||
Ok(vfs)
|
||||
}
|
||||
}
|
70
src/app/vfs/mod.rs
Normal file
70
src/app/vfs/mod.rs
Normal file
|
@ -0,0 +1,70 @@
|
|||
use anyhow::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::db::mount_points;
|
||||
|
||||
mod manager;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub use manager::*;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "mount_points"]
|
||||
pub struct MountPoint {
|
||||
pub source: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub struct VFS {
|
||||
mount_points: HashMap<String, PathBuf>,
|
||||
}
|
||||
|
||||
impl VFS {
|
||||
pub fn new() -> VFS {
|
||||
VFS {
|
||||
mount_points: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount(&mut self, real_path: &Path, name: &str) -> Result<()> {
|
||||
self.mount_points
|
||||
.insert(name.to_owned(), real_path.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn real_to_virtual<P: AsRef<Path>>(&self, real_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
if let Ok(p) = real_path.as_ref().strip_prefix(target) {
|
||||
let mount_path = Path::new(&name);
|
||||
return if p.components().count() == 0 {
|
||||
Ok(mount_path.to_path_buf())
|
||||
} else {
|
||||
Ok(mount_path.join(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
bail!("Real path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn virtual_to_real<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
let mount_path = Path::new(&name);
|
||||
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
|
||||
return if p.components().count() == 0 {
|
||||
Ok(target.clone())
|
||||
} else {
|
||||
Ok(target.join(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
bail!("Virtual path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn get_mount_points(&self) -> &HashMap<String, PathBuf> {
|
||||
&self.mount_points
|
||||
}
|
||||
}
|
50
src/app/vfs/test.rs
Normal file
50
src/app/vfs/test.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_virtual_to_real() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("test_dir");
|
||||
correct_path.push("somewhere");
|
||||
correct_path.push("something.png");
|
||||
|
||||
let mut virtual_path = PathBuf::new();
|
||||
virtual_path.push("root");
|
||||
virtual_path.push("somewhere");
|
||||
virtual_path.push("something.png");
|
||||
|
||||
let found_path = vfs.virtual_to_real(virtual_path.as_path()).unwrap();
|
||||
assert!(found_path.to_str() == correct_path.to_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_virtual_to_real_no_trail() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
let correct_path = Path::new("test_dir");
|
||||
let found_path = vfs.virtual_to_real(Path::new("root")).unwrap();
|
||||
assert!(found_path.to_str() == correct_path.to_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_real_to_virtual() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("root");
|
||||
correct_path.push("somewhere");
|
||||
correct_path.push("something.png");
|
||||
|
||||
let mut real_path = PathBuf::new();
|
||||
real_path.push("test_dir");
|
||||
real_path.push("somewhere");
|
||||
real_path.push("something.png");
|
||||
|
||||
let found_path = vfs.real_to_virtual(real_path.as_path()).unwrap();
|
||||
assert!(found_path == correct_path);
|
||||
}
|
535
src/config.rs
535
src/config.rs
|
@ -1,535 +0,0 @@
|
|||
use anyhow::*;
|
||||
use core::ops::Deref;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path;
|
||||
use toml;
|
||||
|
||||
use crate::db::{ddns_config, misc_settings, mount_points, users, DB};
|
||||
use crate::ddns::DDNSConfig;
|
||||
use crate::user::*;
|
||||
use crate::vfs::MountPoint;
|
||||
|
||||
#[derive(Debug, Queryable)]
|
||||
pub struct MiscSettings {
|
||||
id: i32,
|
||||
pub auth_secret: Vec<u8>,
|
||||
pub index_sleep_duration_seconds: i32,
|
||||
pub index_album_art_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub lastfm_username: Option<String>,
|
||||
pub web_theme_base: Option<String>,
|
||||
pub web_theme_accent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub album_art_pattern: Option<String>,
|
||||
pub reindex_every_n_seconds: Option<i32>,
|
||||
pub mount_dirs: Option<Vec<MountPoint>>,
|
||||
pub users: Option<Vec<ConfigUser>>,
|
||||
pub ydns: Option<DDNSConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn clean_paths(&mut self) -> Result<()> {
|
||||
if let Some(ref mut mount_dirs) = self.mount_dirs {
|
||||
for mount_dir in mount_dirs {
|
||||
match clean_path_string(&mount_dir.source).to_str() {
|
||||
Some(p) => mount_dir.source = p.to_owned(),
|
||||
_ => bail!("Bad mount directory path"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_toml_file(path: &path::Path) -> Result<Config> {
|
||||
let mut config_file = fs::File::open(path)?;
|
||||
let mut config_file_content = String::new();
|
||||
config_file.read_to_string(&mut config_file_content)?;
|
||||
let mut config = toml::de::from_str::<Config>(&config_file_content)?;
|
||||
config.clean_paths()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn read(db: &DB) -> Result<Config> {
|
||||
use self::ddns_config::dsl::*;
|
||||
use self::misc_settings::dsl::*;
|
||||
|
||||
let connection = db.connect()?;
|
||||
|
||||
let mut config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: None,
|
||||
ydns: None,
|
||||
};
|
||||
|
||||
let (art_pattern, sleep_duration) = misc_settings
|
||||
.select((index_album_art_pattern, index_sleep_duration_seconds))
|
||||
.get_result(&connection)?;
|
||||
|
||||
config.album_art_pattern = Some(art_pattern);
|
||||
config.reindex_every_n_seconds = Some(sleep_duration);
|
||||
|
||||
let mount_dirs;
|
||||
{
|
||||
use self::mount_points::dsl::*;
|
||||
mount_dirs = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
config.mount_dirs = Some(mount_dirs);
|
||||
}
|
||||
|
||||
let found_users: Vec<(String, i32)> = users::table
|
||||
.select((users::columns::name, users::columns::admin))
|
||||
.get_results(&connection)?;
|
||||
config.users = Some(
|
||||
found_users
|
||||
.into_iter()
|
||||
.map(|(name, admin)| ConfigUser {
|
||||
name,
|
||||
password: "".to_owned(),
|
||||
admin: admin != 0,
|
||||
})
|
||||
.collect::<_>(),
|
||||
);
|
||||
|
||||
let ydns = ddns_config
|
||||
.select((host, username, password))
|
||||
.get_result(&connection)?;
|
||||
config.ydns = Some(ydns);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn reset(db: &DB) -> Result<()> {
|
||||
use self::ddns_config::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
|
||||
diesel::delete(mount_points::table).execute(&connection)?;
|
||||
diesel::delete(users::table).execute(&connection)?;
|
||||
diesel::update(ddns_config)
|
||||
.set((host.eq(""), username.eq(""), password.eq("")))
|
||||
.execute(&connection)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn amend(db: &DB, new_config: &Config) -> Result<()> {
|
||||
let connection = db.connect()?;
|
||||
|
||||
if let Some(ref mount_dirs) = new_config.mount_dirs {
|
||||
diesel::delete(mount_points::table).execute(&connection)?;
|
||||
diesel::insert_into(mount_points::table)
|
||||
.values(mount_dirs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
}
|
||||
|
||||
if let Some(ref config_users) = new_config.users {
|
||||
let old_usernames: Vec<String> =
|
||||
users::table.select(users::name).get_results(&connection)?;
|
||||
|
||||
// Delete users that are not in new list
|
||||
let delete_usernames: Vec<String> = old_usernames
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|old_name| config_users.iter().find(|u| &u.name == old_name).is_none())
|
||||
.collect::<_>();
|
||||
diesel::delete(users::table.filter(users::name.eq_any(&delete_usernames)))
|
||||
.execute(&connection)?;
|
||||
|
||||
// Insert new users
|
||||
let insert_users: Vec<&ConfigUser> = config_users
|
||||
.iter()
|
||||
.filter(|u| {
|
||||
!u.name.is_empty()
|
||||
&& !u.password.is_empty()
|
||||
&& old_usernames
|
||||
.iter()
|
||||
.find(|old_name| *old_name == &u.name)
|
||||
.is_none()
|
||||
})
|
||||
.collect::<_>();
|
||||
for config_user in &insert_users {
|
||||
let new_user = User::new(&config_user.name, &config_user.password)?;
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
// Update users
|
||||
for user in config_users.iter() {
|
||||
// Update password if provided
|
||||
if !user.password.is_empty() {
|
||||
let hash = hash_password(&user.password)?;
|
||||
diesel::update(users::table.filter(users::name.eq(&user.name)))
|
||||
.set(users::password_hash.eq(hash))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
// Update admin rights
|
||||
diesel::update(users::table.filter(users::name.eq(&user.name)))
|
||||
.set(users::admin.eq(user.admin as i32))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sleep_duration) = new_config.reindex_every_n_seconds {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_sleep_duration_seconds.eq(sleep_duration as i32))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref album_art_pattern) = new_config.album_art_pattern {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_album_art_pattern.eq(album_art_pattern))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref ydns) = new_config.ydns {
|
||||
use self::ddns_config::dsl::*;
|
||||
diesel::update(ddns_config)
|
||||
.set((
|
||||
host.eq(ydns.host.clone()),
|
||||
username.eq(ydns.username.clone()),
|
||||
password.eq(ydns.password.clone()),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_preferences(db: &DB, username: &str) -> Result<Preferences> {
|
||||
use self::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
let (theme_base, theme_accent, read_lastfm_username) = users
|
||||
.select((web_theme_base, web_theme_accent, lastfm_username))
|
||||
.filter(name.eq(username))
|
||||
.get_result(&connection)?;
|
||||
Ok(Preferences {
|
||||
web_theme_base: theme_base,
|
||||
web_theme_accent: theme_accent,
|
||||
lastfm_username: read_lastfm_username,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_preferences(db: &DB, username: &str, preferences: &Preferences) -> Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((
|
||||
web_theme_base.eq(&preferences.web_theme_base),
|
||||
web_theme_accent.eq(&preferences.web_theme_accent),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_auth_secret(db: &DB) -> Result<Vec<u8>> {
|
||||
use self::misc_settings::dsl::*;
|
||||
|
||||
let connection = db.connect()?;
|
||||
|
||||
match misc_settings.select(auth_secret).get_result(&connection) {
|
||||
Err(diesel::result::Error::NotFound) => bail!("Cannot find authentication secret"),
|
||||
Ok(secret) => Ok(secret),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_path_string(path_string: &str) -> path::PathBuf {
|
||||
let separator_regex = Regex::new(r"\\|/").unwrap();
|
||||
let mut correct_separator = String::new();
|
||||
correct_separator.push(path::MAIN_SEPARATOR);
|
||||
let path_string = separator_regex.replace_all(path_string, correct_separator.as_str());
|
||||
path::Path::new(path_string.deref()).iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_test_db(name: &str) -> crate::db::DB {
|
||||
let mut db_path = path::PathBuf::new();
|
||||
db_path.push("test-output");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
db_path.push(name);
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
crate::db::DB::new(&db_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend() {
|
||||
let db = get_test_db("amend.sqlite");
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: Some("file\\.png".into()),
|
||||
reindex_every_n_seconds: Some(123),
|
||||
mount_dirs: Some(vec![MountPoint {
|
||||
source: "C:\\Music".into(),
|
||||
name: "root".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: Some("🖼️\\.jpg".into()),
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: Some(vec![MountPoint {
|
||||
source: "/home/music".into(),
|
||||
name: "🎵📁".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "🐞🐞".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: Some(DDNSConfig {
|
||||
host: "🐸🐸🐸.ydns.eu".into(),
|
||||
username: "kfr🐸g".into(),
|
||||
password: "tasty🐞".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let mut expected_config = new_config.clone();
|
||||
expected_config.reindex_every_n_seconds = initial_config.reindex_every_n_seconds;
|
||||
if let Some(ref mut users) = expected_config.users {
|
||||
users[0].password = "".into();
|
||||
}
|
||||
|
||||
amend(&db, &initial_config).unwrap();
|
||||
amend(&db, &new_config).unwrap();
|
||||
let db_config = read(&db).unwrap();
|
||||
assert_eq!(db_config, expected_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_preserve_password_hashes() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db("amend_preserve_password_hashes.sqlite");
|
||||
let initial_hash: String;
|
||||
let new_hash: String;
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
initial_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![
|
||||
ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "tasty🐞".into(),
|
||||
admin: false,
|
||||
},
|
||||
ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
},
|
||||
]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
new_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(new_hash, initial_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_ignore_blank_users() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db("amend_ignore_blank_users.sqlite");
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_admin() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db("amend_toggle_admin.sqlite");
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: true,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 1);
|
||||
}
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferences_read_write() {
|
||||
let db = get_test_db("preferences_read_write.sqlite");
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
amend(&db, &initial_config).unwrap();
|
||||
|
||||
let new_preferences = Preferences {
|
||||
web_theme_base: Some("very-dark-theme".to_owned()),
|
||||
web_theme_accent: Some("#FF0000".to_owned()),
|
||||
lastfm_username: None,
|
||||
};
|
||||
write_preferences(&db, "Teddy🐻", &new_preferences).unwrap();
|
||||
|
||||
let read_preferences = read_preferences(&db, "Teddy🐻").unwrap();
|
||||
assert_eq!(new_preferences, read_preferences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_path_string() {
|
||||
let mut correct_path = path::PathBuf::new();
|
||||
if cfg!(target_os = "windows") {
|
||||
correct_path.push("C:\\");
|
||||
} else {
|
||||
correct_path.push("/usr");
|
||||
}
|
||||
correct_path.push("some");
|
||||
correct_path.push("path");
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(correct_path, clean_path_string(r#"C:/some/path"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"C:\some\path"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"C:\some\path\"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"C:\some\path\\\\"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"C:\some/path//"#));
|
||||
} else {
|
||||
assert_eq!(correct_path, clean_path_string(r#"/usr/some/path"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"/usr\some\path"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"/usr\some\path\"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"/usr\some\path\\\\"#));
|
||||
assert_eq!(correct_path, clean_path_string(r#"/usr\some/path//"#));
|
||||
}
|
||||
}
|
|
@ -89,9 +89,9 @@ impl DB {
|
|||
|
||||
#[cfg(test)]
|
||||
pub fn get_test_db(name: &str) -> DB {
|
||||
use crate::config;
|
||||
use crate::app::{config, user};
|
||||
let config_path = Path::new("test-data/config.toml");
|
||||
let config = config::parse_toml_file(&config_path).unwrap();
|
||||
let config = config::Config::from_path(&config_path).unwrap();
|
||||
|
||||
let mut db_path = std::path::PathBuf::new();
|
||||
db_path.push("test-output");
|
||||
|
@ -103,9 +103,10 @@ pub fn get_test_db(name: &str) -> DB {
|
|||
}
|
||||
|
||||
let db = DB::new(&db_path).unwrap();
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
|
||||
config::reset(&db).unwrap();
|
||||
config::amend(&db, &config).unwrap();
|
||||
config_manager.amend(&config).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
|
|
65
src/ddns.rs
65
src/ddns.rs
|
@ -1,65 +0,0 @@
|
|||
use anyhow::*;
|
||||
use diesel::prelude::*;
|
||||
use log::{error, info};
|
||||
use reqwest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use crate::db::ddns_config;
|
||||
use crate::db::DB;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "ddns_config"]
|
||||
pub struct DDNSConfig {
|
||||
pub host: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub trait DDNSConfigSource {
|
||||
fn get_ddns_config(&self) -> Result<DDNSConfig>;
|
||||
}
|
||||
|
||||
impl DDNSConfigSource for DB {
|
||||
fn get_ddns_config(&self) -> Result<DDNSConfig> {
|
||||
use self::ddns_config::dsl::*;
|
||||
let connection = self.connect()?;
|
||||
Ok(ddns_config
|
||||
.select((host, username, password))
|
||||
.get_result(&connection)?)
|
||||
}
|
||||
}
|
||||
|
||||
const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/";
|
||||
|
||||
fn update_my_ip(config_source: &DB) -> Result<()> {
|
||||
let config = config_source.get_ddns_config()?;
|
||||
if config.host.is_empty() || config.username.is_empty() {
|
||||
info!("Skipping DDNS update because credentials are missing");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.host);
|
||||
let client = reqwest::ClientBuilder::new().build()?;
|
||||
let res = client
|
||||
.get(full_url.as_str())
|
||||
.basic_auth(config.username, Some(config.password))
|
||||
.send()?;
|
||||
if !res.status().is_success() {
|
||||
bail!(
|
||||
"DDNS update query failed with status code: {}",
|
||||
res.status()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(config_source: &DB) {
|
||||
loop {
|
||||
if let Err(e) = update_my_ip(config_source) {
|
||||
error!("Dynamic DNS update error: {:?}", e);
|
||||
}
|
||||
thread::sleep(time::Duration::from_secs(60 * 30));
|
||||
}
|
||||
}
|
|
@ -1,234 +0,0 @@
|
|||
use anyhow::*;
|
||||
use diesel;
|
||||
use diesel::dsl::sql;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::{directories, songs, DB};
|
||||
use crate::index::*;
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum QueryError {
|
||||
#[error("VFS path not found")]
|
||||
VFSPathNotFound,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for QueryError {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
QueryError::Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
no_arg_sql_function!(
|
||||
random,
|
||||
sql_types::Integer,
|
||||
"Represents the SQL RANDOM() function"
|
||||
);
|
||||
|
||||
pub fn virtualize_song(vfs: &VFS, mut song: Song) -> Option<Song> {
|
||||
song.path = match vfs.real_to_virtual(Path::new(&song.path)) {
|
||||
Ok(p) => p.to_string_lossy().into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(artwork_path) = song.artwork {
|
||||
song.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
|
||||
Ok(p) => Some(p.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(song)
|
||||
}
|
||||
|
||||
fn virtualize_directory(vfs: &VFS, mut directory: Directory) -> Option<Directory> {
|
||||
directory.path = match vfs.real_to_virtual(Path::new(&directory.path)) {
|
||||
Ok(p) => p.to_string_lossy().into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(artwork_path) = directory.artwork {
|
||||
directory.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
|
||||
Ok(p) => Some(p.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(directory)
|
||||
}
|
||||
|
||||
pub fn browse<P>(db: &DB, virtual_path: P) -> Result<Vec<CollectionFile>, QueryError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let mut output = Vec::new();
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
|
||||
if virtual_path.as_ref().components().count() == 0 {
|
||||
// Browse top-level
|
||||
let real_directories: Vec<Directory> = directories::table
|
||||
.filter(directories::parent.is_null())
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_directory(&vfs, s));
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
} else {
|
||||
// Browse sub-directory
|
||||
let real_path = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.map_err(|_| QueryError::VFSPathNotFound)?;
|
||||
let real_path_string = real_path.as_path().to_string_lossy().into_owned();
|
||||
|
||||
let real_directories: Vec<Directory> = directories::table
|
||||
.filter(directories::parent.eq(&real_path_string))
|
||||
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC"))
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_directory(&vfs, s));
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
|
||||
let real_songs: Vec<Song> = songs::table
|
||||
.filter(songs::parent.eq(&real_path_string))
|
||||
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC"))
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let virtual_songs = real_songs
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_song(&vfs, s));
|
||||
output.extend(virtual_songs.map(CollectionFile::Song));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn flatten<P>(db: &DB, virtual_path: P) -> Result<Vec<Song>, QueryError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
use self::songs::dsl::*;
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
|
||||
let real_songs: Vec<Song> = if virtual_path.as_ref().parent() != None {
|
||||
let real_path = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.map_err(|_| QueryError::VFSPathNotFound)?;
|
||||
let song_path_filter = {
|
||||
let mut path_buf = real_path.clone();
|
||||
path_buf.push("%");
|
||||
path_buf.as_path().to_string_lossy().into_owned()
|
||||
};
|
||||
songs
|
||||
.filter(path.like(&song_path_filter))
|
||||
.order(path)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
} else {
|
||||
songs
|
||||
.order(path)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
};
|
||||
|
||||
let virtual_songs = real_songs
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_song(&vfs, s));
|
||||
Ok(virtual_songs.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn get_random_albums(db: &DB, count: i64) -> Result<Vec<Directory>> {
|
||||
use self::directories::dsl::*;
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
let real_directories = directories
|
||||
.filter(album.is_not_null())
|
||||
.limit(count)
|
||||
.order(random)
|
||||
.load(&connection)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_directory(&vfs, s));
|
||||
Ok(virtual_directories.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn get_recent_albums(db: &DB, count: i64) -> Result<Vec<Directory>> {
|
||||
use self::directories::dsl::*;
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
let real_directories: Vec<Directory> = directories
|
||||
.filter(album.is_not_null())
|
||||
.order(date_added.desc())
|
||||
.limit(count)
|
||||
.load(&connection)?;
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_directory(&vfs, s));
|
||||
Ok(virtual_directories.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn search(db: &DB, query: &str) -> Result<Vec<CollectionFile>> {
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
let like_test = format!("%{}%", query);
|
||||
let mut output = Vec::new();
|
||||
|
||||
// Find dirs with matching path and parent not matching
|
||||
{
|
||||
use self::directories::dsl::*;
|
||||
let real_directories: Vec<Directory> = directories
|
||||
.filter(path.like(&like_test))
|
||||
.filter(parent.not_like(&like_test))
|
||||
.load(&connection)?;
|
||||
|
||||
let virtual_directories = real_directories
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_directory(&vfs, s));
|
||||
|
||||
output.extend(virtual_directories.map(CollectionFile::Directory));
|
||||
}
|
||||
|
||||
// Find songs with matching title/album/artist and non-matching parent
|
||||
{
|
||||
use self::songs::dsl::*;
|
||||
let real_songs: Vec<Song> = songs
|
||||
.filter(
|
||||
path.like(&like_test)
|
||||
.or(title.like(&like_test))
|
||||
.or(album.like(&like_test))
|
||||
.or(artist.like(&like_test))
|
||||
.or(album_artist.like(&like_test)),
|
||||
)
|
||||
.filter(parent.not_like(&like_test))
|
||||
.load(&connection)?;
|
||||
|
||||
let virtual_songs = real_songs
|
||||
.into_iter()
|
||||
.filter_map(|s| virtualize_song(&vfs, s));
|
||||
|
||||
output.extend(virtual_songs.map(CollectionFile::Song));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn get_song(db: &DB, virtual_path: &Path) -> Result<Song> {
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.connect()?;
|
||||
let real_path = vfs.virtual_to_real(virtual_path)?;
|
||||
let real_path_string = real_path.as_path().to_string_lossy();
|
||||
|
||||
use self::songs::dsl::*;
|
||||
let real_song: Song = songs
|
||||
.filter(path.eq(real_path_string))
|
||||
.get_result(&connection)?;
|
||||
|
||||
match virtualize_song(&vfs, real_song) {
|
||||
Some(s) => Ok(s),
|
||||
_ => bail!("Missing VFS mapping"),
|
||||
}
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::db;
|
||||
use crate::db::{directories, songs};
|
||||
use crate::index::*;
|
||||
|
||||
#[test]
|
||||
fn test_populate() {
|
||||
let db = db::get_test_db("populate.sqlite");
|
||||
update(&db).unwrap();
|
||||
update(&db).unwrap(); // Check that subsequent updates don't run into conflicts
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let all_directories: Vec<Directory> = directories::table.load(&connection).unwrap();
|
||||
let all_songs: Vec<Song> = songs::table.load(&connection).unwrap();
|
||||
assert_eq!(all_directories.len(), 6);
|
||||
assert_eq!(all_songs.len(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata() {
|
||||
let mut target = PathBuf::new();
|
||||
target.push("test-data");
|
||||
target.push("small-collection");
|
||||
target.push("Tobokegao");
|
||||
target.push("Picnic");
|
||||
|
||||
let mut song_path = target.clone();
|
||||
song_path.push("05 - シャーベット (Sherbet).mp3");
|
||||
|
||||
let mut artwork_path = target.clone();
|
||||
artwork_path.push("Folder.png");
|
||||
|
||||
let db = db::get_test_db("metadata.sqlite");
|
||||
update(&db).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let songs: Vec<Song> = songs::table
|
||||
.filter(songs::title.eq("シャーベット (Sherbet)"))
|
||||
.load(&connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(songs.len(), 1);
|
||||
let song = &songs[0];
|
||||
assert_eq!(song.path, song_path.to_string_lossy().as_ref());
|
||||
assert_eq!(song.track_number, Some(5));
|
||||
assert_eq!(song.disc_number, None);
|
||||
assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned()));
|
||||
assert_eq!(song.artist, Some("Tobokegao".to_owned()));
|
||||
assert_eq!(song.album_artist, None);
|
||||
assert_eq!(song.album, Some("Picnic".to_owned()));
|
||||
assert_eq!(song.year, Some(2016));
|
||||
assert_eq!(
|
||||
song.artwork,
|
||||
Some(artwork_path.to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedded_artwork() {
|
||||
let mut song_path = PathBuf::new();
|
||||
song_path.push("test-data");
|
||||
song_path.push("small-collection");
|
||||
song_path.push("Tobokegao");
|
||||
song_path.push("Picnic");
|
||||
song_path.push("07 - なぜ (Why).mp3");
|
||||
|
||||
let db = db::get_test_db("artwork.sqlite");
|
||||
update(&db).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let songs: Vec<Song> = songs::table
|
||||
.filter(songs::title.eq("なぜ (Why?)"))
|
||||
.load(&connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(songs.len(), 1);
|
||||
let song = &songs[0];
|
||||
assert_eq!(song.artwork, Some(song_path.to_string_lossy().into_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browse_top_level() {
|
||||
let mut root_path = PathBuf::new();
|
||||
root_path.push("root");
|
||||
|
||||
let db = db::get_test_db("browse_top_level.sqlite");
|
||||
update(&db).unwrap();
|
||||
let results = browse(&db, Path::new("")).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match results[0] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, root_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browse() {
|
||||
let mut khemmis_path = PathBuf::new();
|
||||
khemmis_path.push("root");
|
||||
khemmis_path.push("Khemmis");
|
||||
|
||||
let mut tobokegao_path = PathBuf::new();
|
||||
tobokegao_path.push("root");
|
||||
tobokegao_path.push("Tobokegao");
|
||||
|
||||
let db = db::get_test_db("browse.sqlite");
|
||||
update(&db).unwrap();
|
||||
let results = browse(&db, Path::new("root")).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
match results[0] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, khemmis_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
match results[1] {
|
||||
CollectionFile::Directory(ref d) => assert_eq!(d.path, tobokegao_path.to_str().unwrap()),
|
||||
_ => panic!("Expected directory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten() {
|
||||
let db = db::get_test_db("flatten.sqlite");
|
||||
update(&db).unwrap();
|
||||
|
||||
// Flatten all
|
||||
let results = flatten(&db, Path::new("root")).unwrap();
|
||||
assert_eq!(results.len(), 13);
|
||||
assert_eq!(results[0].title, Some("Above The Water".to_owned()));
|
||||
|
||||
// Flatten a directory
|
||||
let mut path = PathBuf::new();
|
||||
path.push("root");
|
||||
path.push("Tobokegao");
|
||||
let results = flatten(&db, &path).unwrap();
|
||||
assert_eq!(results.len(), 8);
|
||||
|
||||
// Flatten a directory that is a prefix of another directory (Picnic Remixes)
|
||||
let mut path = PathBuf::new();
|
||||
path.push("root");
|
||||
path.push("Tobokegao");
|
||||
path.push("Picnic");
|
||||
let results = flatten(&db, &path).unwrap();
|
||||
assert_eq!(results.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random() {
|
||||
let db = db::get_test_db("random.sqlite");
|
||||
update(&db).unwrap();
|
||||
let results = get_random_albums(&db, 1).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recent() {
|
||||
let db = db::get_test_db("recent.sqlite");
|
||||
update(&db).unwrap();
|
||||
let results = get_recent_albums(&db, 2).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].date_added >= results[1].date_added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_song() {
|
||||
let db = db::get_test_db("get_song.sqlite");
|
||||
update(&db).unwrap();
|
||||
|
||||
let mut song_path = PathBuf::new();
|
||||
song_path.push("root");
|
||||
song_path.push("Khemmis");
|
||||
song_path.push("Hunted");
|
||||
song_path.push("02 - Candlelight.mp3");
|
||||
|
||||
let song = get_song(&db, &song_path).unwrap();
|
||||
assert_eq!(song.title.unwrap(), "Candlelight");
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
use anyhow::*;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use log::{error, info};
|
||||
use regex::Regex;
|
||||
use std::time;
|
||||
|
||||
use crate::config::MiscSettings;
|
||||
use crate::db::{misc_settings, DB};
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
mod cleaner;
|
||||
mod collector;
|
||||
mod inserter;
|
||||
mod traverser;
|
||||
|
||||
use cleaner::Cleaner;
|
||||
use collector::Collector;
|
||||
use inserter::Inserter;
|
||||
use traverser::Traverser;
|
||||
|
||||
pub fn update(db: &DB) -> Result<()> {
|
||||
let start = time::Instant::now();
|
||||
info!("Beginning library index update");
|
||||
|
||||
let album_art_pattern = {
|
||||
let connection = db.connect()?;
|
||||
let settings: MiscSettings = misc_settings::table.get_result(&connection)?;
|
||||
Regex::new(&settings.index_album_art_pattern)?
|
||||
};
|
||||
|
||||
let cleaner = Cleaner::new(db.clone());
|
||||
cleaner.clean()?;
|
||||
|
||||
let (insert_sender, insert_receiver) = crossbeam_channel::unbounded();
|
||||
let inserter_db = db.clone();
|
||||
let insertion_thread = std::thread::spawn(move || {
|
||||
let mut inserter = Inserter::new(inserter_db, insert_receiver);
|
||||
inserter.insert();
|
||||
});
|
||||
|
||||
let (collect_sender, collect_receiver) = crossbeam_channel::unbounded();
|
||||
let collector_thread = std::thread::spawn(move || {
|
||||
let collector = Collector::new(collect_receiver, insert_sender, album_art_pattern);
|
||||
collector.collect();
|
||||
});
|
||||
|
||||
let vfs = db.get_vfs()?;
|
||||
let traverser_thread = std::thread::spawn(move || {
|
||||
let mount_points = vfs.get_mount_points();
|
||||
let traverser = Traverser::new(collect_sender);
|
||||
traverser.traverse(mount_points.values().map(|p| p.clone()).collect());
|
||||
});
|
||||
|
||||
if let Err(e) = traverser_thread.join() {
|
||||
error!("Error joining on traverser thread: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = collector_thread.join() {
|
||||
error!("Error joining on collector thread: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = insertion_thread.join() {
|
||||
error!("Error joining on inserter thread: {:?}", e);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Library index update took {} seconds",
|
||||
start.elapsed().as_millis() as f32 / 1000.0
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
use anyhow::*;
|
||||
use rustfm_scrobble::{Scrobble, Scrobbler};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DB;
|
||||
use crate::index;
|
||||
use crate::user;
|
||||
|
||||
const LASTFM_API_KEY: &str = "02b96c939a2b451c31dfd67add1f696e";
|
||||
const LASTFM_API_SECRET: &str = "0f25a80ceef4b470b5cb97d99d4b3420";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionName {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionKey {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSessionSubscriber {
|
||||
#[serde(rename = "$value")]
|
||||
pub body: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponseSession {
|
||||
pub name: AuthResponseSessionName,
|
||||
pub key: AuthResponseSessionKey,
|
||||
pub subscriber: AuthResponseSessionSubscriber,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponse {
|
||||
pub status: String,
|
||||
pub session: AuthResponseSession,
|
||||
}
|
||||
|
||||
fn scrobble_from_path(db: &DB, track: &Path) -> Result<Scrobble> {
|
||||
let song = index::get_song(db, track)?;
|
||||
Ok(Scrobble::new(
|
||||
song.artist.as_deref().unwrap_or(""),
|
||||
song.title.as_deref().unwrap_or(""),
|
||||
song.album.as_deref().unwrap_or(""),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn link(db: &DB, username: &str, token: &str) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let auth_response = scrobbler.authenticate_with_token(token)?;
|
||||
|
||||
user::lastfm_link(db, username, &auth_response.name, &auth_response.key)
|
||||
}
|
||||
|
||||
pub fn unlink(db: &DB, username: &str) -> Result<()> {
|
||||
user::lastfm_unlink(db, username)
|
||||
}
|
||||
|
||||
pub fn scrobble(db: &DB, username: &str, track: &Path) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = scrobble_from_path(db, track)?;
|
||||
let auth_token = user::get_lastfm_session_key(db, username)?;
|
||||
scrobbler.authenticate_with_session_key(&auth_token);
|
||||
scrobbler.scrobble(&scrobble)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn now_playing(db: &DB, username: &str, track: &Path) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let scrobble = scrobble_from_path(db, track)?;
|
||||
let auth_token = user::get_lastfm_session_key(db, username)?;
|
||||
scrobbler.authenticate_with_session_key(&auth_token);
|
||||
scrobbler.now_playing(&scrobble)?;
|
||||
Ok(())
|
||||
}
|
20
src/main.rs
20
src/main.rs
|
@ -10,20 +10,14 @@ use anyhow::*;
|
|||
use log::{error, info};
|
||||
use simplelog::{LevelFilter, SimpleLogger, TermLogger, TerminalMode};
|
||||
|
||||
mod artwork;
|
||||
mod config;
|
||||
mod app;
|
||||
mod db;
|
||||
mod ddns;
|
||||
mod index;
|
||||
mod lastfm;
|
||||
mod options;
|
||||
mod playlist;
|
||||
mod service;
|
||||
mod thumbnails;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
mod ui;
|
||||
mod user;
|
||||
mod utils;
|
||||
mod vfs;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn daemonize(
|
||||
|
@ -101,7 +95,7 @@ fn init_logging(cli_options: &options::CLIOptions) -> Result<()> {
|
|||
fn main() -> Result<()> {
|
||||
// Parse CLI options
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let options_manager = options::OptionsManager::new();
|
||||
let options_manager = options::Manager::new();
|
||||
let cli_options = options_manager.parse(&args[1..])?;
|
||||
|
||||
if cli_options.show_help {
|
||||
|
@ -148,16 +142,16 @@ fn main() -> Result<()> {
|
|||
info!("Swagger files location is {:#?}", context.swagger_dir_path);
|
||||
info!(
|
||||
"Thumbnails files location is {:#?}",
|
||||
context.thumbnails_manager.get_directory()
|
||||
context.thumbnail_manager.get_directory()
|
||||
);
|
||||
|
||||
// Begin collection scans
|
||||
context.index.begin_periodic_updates();
|
||||
|
||||
// Start DDNS updates
|
||||
let db_ddns = context.db.clone();
|
||||
let ddns_manager = app::ddns::Manager::new(context.db.clone());
|
||||
std::thread::spawn(move || {
|
||||
ddns::run(&db_ddns);
|
||||
ddns_manager.run();
|
||||
});
|
||||
|
||||
// Start server
|
||||
|
|
|
@ -17,11 +17,11 @@ pub struct CLIOptions {
|
|||
pub log_level: Option<LevelFilter>,
|
||||
}
|
||||
|
||||
pub struct OptionsManager {
|
||||
pub struct Manager {
|
||||
protocol: getopts::Options,
|
||||
}
|
||||
|
||||
impl OptionsManager {
|
||||
impl Manager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
protocol: get_options(),
|
||||
|
|
331
src/playlist.rs
331
src/playlist.rs
|
@ -1,331 +0,0 @@
|
|||
use anyhow::*;
|
||||
use core::clone::Clone;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types;
|
||||
use diesel::BelongingToDsl;
|
||||
use std::path::Path;
|
||||
#[cfg(test)]
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::db;
|
||||
use crate::db::DB;
|
||||
use crate::db::{playlist_songs, playlists, users};
|
||||
use crate::index::{self, Song};
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PlaylistError {
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
#[error("Playlist not found")]
|
||||
PlaylistNotFound,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for PlaylistError {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
PlaylistError::Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "playlists"]
|
||||
struct NewPlaylist {
|
||||
name: String,
|
||||
owner: i32,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable)]
|
||||
pub struct User {
|
||||
id: i32,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[belongs_to(User, foreign_key = "owner")]
|
||||
pub struct Playlist {
|
||||
id: i32,
|
||||
owner: i32,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[belongs_to(Playlist, foreign_key = "playlist")]
|
||||
pub struct PlaylistSong {
|
||||
id: i32,
|
||||
playlist: i32,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "playlist_songs"]
|
||||
pub struct NewPlaylistSong {
|
||||
playlist: i32,
|
||||
path: String,
|
||||
ordering: i32,
|
||||
}
|
||||
|
||||
pub fn list_playlists(owner: &str, db: &DB) -> Result<Vec<String>, PlaylistError> {
|
||||
let connection = db.connect()?;
|
||||
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(PlaylistError::UserNotFound)?
|
||||
};
|
||||
|
||||
{
|
||||
use self::playlists::dsl::*;
|
||||
let found_playlists: Vec<String> = Playlist::belonging_to(&user)
|
||||
.select(name)
|
||||
.load(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
Ok(found_playlists)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_playlist(
|
||||
playlist_name: &str,
|
||||
owner: &str,
|
||||
content: &[String],
|
||||
db: &DB,
|
||||
) -> Result<(), PlaylistError> {
|
||||
let new_playlist: NewPlaylist;
|
||||
let playlist: Playlist;
|
||||
let vfs = db.get_vfs()?;
|
||||
|
||||
{
|
||||
let connection = db.connect()?;
|
||||
|
||||
// Find owner
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(PlaylistError::UserNotFound)?
|
||||
};
|
||||
|
||||
// Create playlist
|
||||
new_playlist = NewPlaylist {
|
||||
name: playlist_name.into(),
|
||||
owner: user.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(playlists::table)
|
||||
.values(&new_playlist)
|
||||
.execute(&connection)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
|
||||
playlist = {
|
||||
use self::playlists::dsl::*;
|
||||
playlists
|
||||
.select((id, owner))
|
||||
.filter(name.eq(playlist_name).and(owner.eq(user.id)))
|
||||
.get_result(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_songs: Vec<NewPlaylistSong> = Vec::new();
|
||||
new_songs.reserve(content.len());
|
||||
|
||||
for (i, path) in content.iter().enumerate() {
|
||||
let virtual_path = Path::new(&path);
|
||||
if let Some(real_path) = vfs
|
||||
.virtual_to_real(virtual_path)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| s.to_owned()))
|
||||
{
|
||||
new_songs.push(NewPlaylistSong {
|
||||
playlist: playlist.id,
|
||||
path: real_path,
|
||||
ordering: i as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let connection = db.connect()?;
|
||||
connection
|
||||
.transaction::<_, diesel::result::Error, _>(|| {
|
||||
// Delete old content (if any)
|
||||
let old_songs = PlaylistSong::belonging_to(&playlist);
|
||||
diesel::delete(old_songs).execute(&connection)?;
|
||||
|
||||
// Insert content
|
||||
diesel::insert_into(playlist_songs::table)
|
||||
.values(&new_songs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
Ok(())
|
||||
})
|
||||
.map_err(anyhow::Error::new)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_playlist(
|
||||
playlist_name: &str,
|
||||
owner: &str,
|
||||
db: &DB,
|
||||
) -> Result<Vec<Song>, PlaylistError> {
|
||||
let vfs = db.get_vfs()?;
|
||||
let songs: Vec<Song>;
|
||||
|
||||
{
|
||||
let connection = db.connect()?;
|
||||
|
||||
// Find owner
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(PlaylistError::UserNotFound)?
|
||||
};
|
||||
|
||||
// Find playlist
|
||||
let playlist: Playlist = {
|
||||
use self::playlists::dsl::*;
|
||||
playlists
|
||||
.select((id, owner))
|
||||
.filter(name.eq(playlist_name).and(owner.eq(user.id)))
|
||||
.get_result(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(PlaylistError::PlaylistNotFound)?
|
||||
};
|
||||
|
||||
// Select songs. Not using Diesel because we need to LEFT JOIN using a custom column
|
||||
let query = diesel::sql_query(
|
||||
r#"
|
||||
SELECT s.id, s.path, s.parent, s.track_number, s.disc_number, s.title, s.artist, s.album_artist, s.year, s.album, s.artwork, s.duration
|
||||
FROM playlist_songs ps
|
||||
LEFT JOIN songs s ON ps.path = s.path
|
||||
WHERE ps.playlist = ?
|
||||
ORDER BY ps.ordering
|
||||
"#,
|
||||
);
|
||||
let query = query.clone().bind::<sql_types::Integer, _>(playlist.id);
|
||||
songs = query.get_results(&connection).map_err(anyhow::Error::new)?;
|
||||
}
|
||||
|
||||
// Map real path to virtual paths
|
||||
let virtual_songs = songs
|
||||
.into_iter()
|
||||
.filter_map(|s| index::virtualize_song(&vfs, s))
|
||||
.collect();
|
||||
|
||||
Ok(virtual_songs)
|
||||
}
|
||||
|
||||
pub fn delete_playlist(playlist_name: &str, owner: &str, db: &DB) -> Result<(), PlaylistError> {
|
||||
let connection = db.connect()?;
|
||||
|
||||
let user: User = {
|
||||
use self::users::dsl::*;
|
||||
users
|
||||
.filter(name.eq(owner))
|
||||
.select((id,))
|
||||
.first(&connection)
|
||||
.optional()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.ok_or(PlaylistError::UserNotFound)?
|
||||
};
|
||||
|
||||
{
|
||||
use self::playlists::dsl::*;
|
||||
let q = Playlist::belonging_to(&user).filter(name.eq(playlist_name));
|
||||
match diesel::delete(q)
|
||||
.execute(&connection)
|
||||
.map_err(anyhow::Error::new)?
|
||||
{
|
||||
0 => Err(PlaylistError::PlaylistNotFound),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_playlist() {
|
||||
let db = db::get_test_db("create_playlist.sqlite");
|
||||
|
||||
let found_playlists = list_playlists("test_user", &db).unwrap();
|
||||
assert!(found_playlists.is_empty());
|
||||
|
||||
save_playlist("chill_and_grill", "test_user", &Vec::new(), &db).unwrap();
|
||||
let found_playlists = list_playlists("test_user", &db).unwrap();
|
||||
assert_eq!(found_playlists.len(), 1);
|
||||
assert_eq!(found_playlists[0], "chill_and_grill");
|
||||
|
||||
let found_playlists = list_playlists("someone_else", &db);
|
||||
assert!(found_playlists.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_playlist() {
|
||||
let db = db::get_test_db("delete_playlist.sqlite");
|
||||
let playlist_content = Vec::new();
|
||||
|
||||
save_playlist("chill_and_grill", "test_user", &playlist_content, &db).unwrap();
|
||||
save_playlist("mellow_bungalow", "test_user", &playlist_content, &db).unwrap();
|
||||
let found_playlists = list_playlists("test_user", &db).unwrap();
|
||||
assert_eq!(found_playlists.len(), 2);
|
||||
|
||||
delete_playlist("chill_and_grill", "test_user", &db).unwrap();
|
||||
let found_playlists = list_playlists("test_user", &db).unwrap();
|
||||
assert_eq!(found_playlists.len(), 1);
|
||||
assert_eq!(found_playlists[0], "mellow_bungalow");
|
||||
|
||||
let delete_result = delete_playlist("mellow_bungalow", "someone_else", &db);
|
||||
assert!(delete_result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_playlist() {
|
||||
use crate::index;
|
||||
|
||||
let db = db::get_test_db("fill_playlist.sqlite");
|
||||
index::update(&db).unwrap();
|
||||
|
||||
let mut playlist_content: Vec<String> = index::flatten(&db, Path::new("root"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.path)
|
||||
.collect();
|
||||
assert_eq!(playlist_content.len(), 13);
|
||||
|
||||
let first_song = playlist_content[0].clone();
|
||||
playlist_content.push(first_song);
|
||||
assert_eq!(playlist_content.len(), 14);
|
||||
|
||||
save_playlist("all_the_music", "test_user", &playlist_content, &db).unwrap();
|
||||
|
||||
let songs = read_playlist("all_the_music", "test_user", &db).unwrap();
|
||||
assert_eq!(songs.len(), 14);
|
||||
assert_eq!(songs[0].title, Some("Above The Water".to_owned()));
|
||||
assert_eq!(songs[13].title, Some("Above The Water".to_owned()));
|
||||
|
||||
let first_song_path: PathBuf = ["root", "Khemmis", "Hunted", "01 - Above The Water.mp3"]
|
||||
.iter()
|
||||
.collect();
|
||||
assert_eq!(songs[0].path, first_song_path.to_str().unwrap());
|
||||
|
||||
// Save again to verify that we don't dupe the content
|
||||
save_playlist("all_the_music", "test_user", &playlist_content, &db).unwrap();
|
||||
let songs = read_playlist("all_the_music", "test_user", &db).unwrap();
|
||||
assert_eq!(songs.len(), 14);
|
||||
}
|
|
@ -33,4 +33,4 @@ pub struct SavePlaylistInput {
|
|||
pub tracks: Vec<String>,
|
||||
}
|
||||
|
||||
// TODO: Config and Preferences should have dto types
|
||||
// TODO: Config, Preferences, CollectionFile, Song and Directory should have dto types
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
use crate::db::DB;
|
||||
use crate::index::Index;
|
||||
use crate::thumbnails::ThumbnailsManager;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config;
|
||||
use crate::app::{config, index::Index, lastfm, playlist, thumbnail, user, vfs};
|
||||
use crate::db::DB;
|
||||
|
||||
mod dto;
|
||||
mod error;
|
||||
|
@ -27,7 +25,12 @@ pub struct Context {
|
|||
pub api_url: String,
|
||||
pub db: DB,
|
||||
pub index: Index,
|
||||
pub thumbnails_manager: ThumbnailsManager,
|
||||
pub config_manager: config::Manager,
|
||||
pub lastfm_manager: lastfm::Manager,
|
||||
pub playlist_manager: playlist::Manager,
|
||||
pub thumbnail_manager: thumbnail::Manager,
|
||||
pub user_manager: user::Manager,
|
||||
pub vfs_manager: vfs::Manager,
|
||||
}
|
||||
|
||||
pub struct ContextBuilder {
|
||||
|
@ -60,12 +63,6 @@ impl ContextBuilder {
|
|||
fs::create_dir_all(&db_path.parent().unwrap())?;
|
||||
let db = DB::new(&db_path)?;
|
||||
|
||||
if let Some(config_path) = self.config_file_path {
|
||||
let config = config::parse_toml_file(&config_path)?;
|
||||
config::amend(&db, &config)?;
|
||||
}
|
||||
let auth_secret = config::get_auth_secret(&db)?;
|
||||
|
||||
let web_dir_path = self
|
||||
.web_dir_path
|
||||
.or(option_env!("POLARIS_WEB_DIR").map(PathBuf::from))
|
||||
|
@ -84,6 +81,20 @@ impl ContextBuilder {
|
|||
.unwrap_or(PathBuf::from(".").to_owned());
|
||||
thumbnails_dir_path.push("thumbnails");
|
||||
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager.clone());
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), config_manager.clone());
|
||||
let playlist_manager = playlist::Manager::new(db.clone(), vfs_manager.clone());
|
||||
let thumbnail_manager = thumbnail::Manager::new(thumbnails_dir_path);
|
||||
let lastfm_manager = lastfm::Manager::new(index.clone(), user_manager.clone());
|
||||
|
||||
if let Some(config_path) = self.config_file_path {
|
||||
let config = config::Config::from_path(&config_path)?;
|
||||
config_manager.amend(&config)?;
|
||||
}
|
||||
let auth_secret = config_manager.get_auth_secret()?;
|
||||
|
||||
Ok(Context {
|
||||
port: self.port.unwrap_or(5050),
|
||||
auth_secret,
|
||||
|
@ -92,8 +103,13 @@ impl ContextBuilder {
|
|||
web_url: "/".to_owned(),
|
||||
web_dir_path,
|
||||
swagger_dir_path,
|
||||
thumbnails_manager: ThumbnailsManager::new(thumbnails_dir_path),
|
||||
index: Index::new(db.clone()),
|
||||
index,
|
||||
config_manager,
|
||||
lastfm_manager,
|
||||
playlist_manager,
|
||||
thumbnail_manager,
|
||||
user_manager,
|
||||
vfs_manager,
|
||||
db,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,23 +6,16 @@ use rocket::{delete, get, post, put, routes, Outcome, State};
|
|||
use rocket_contrib::json::Json;
|
||||
use std::default::Default;
|
||||
use std::fs::File;
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str;
|
||||
use std::str::FromStr;
|
||||
use time::Duration;
|
||||
|
||||
use super::serve;
|
||||
use crate::config::{self, Config, Preferences};
|
||||
use crate::db::DB;
|
||||
use crate::index::{self, Index, QueryError};
|
||||
use crate::lastfm;
|
||||
use crate::playlist::{self, PlaylistError};
|
||||
use crate::app::index::{self, Index, QueryError};
|
||||
use crate::app::{config, lastfm, playlist, thumbnail, user, vfs};
|
||||
use crate::service::dto;
|
||||
use crate::service::error::APIError;
|
||||
use crate::thumbnails::{ThumbnailOptions, ThumbnailsManager};
|
||||
use crate::user;
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
pub fn get_routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
|
@ -69,12 +62,12 @@ impl<'r> rocket::response::Responder<'r> for APIError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<PlaylistError> for APIError {
|
||||
fn from(error: PlaylistError) -> APIError {
|
||||
impl From<playlist::Error> for APIError {
|
||||
fn from(error: playlist::Error) -> APIError {
|
||||
match error {
|
||||
PlaylistError::PlaylistNotFound => APIError::PlaylistNotFound,
|
||||
PlaylistError::UserNotFound => APIError::UserNotFound,
|
||||
PlaylistError::Unspecified => APIError::Unspecified,
|
||||
playlist::Error::PlaylistNotFound => APIError::PlaylistNotFound,
|
||||
playlist::Error::UserNotFound => APIError::UserNotFound,
|
||||
playlist::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -125,13 +118,13 @@ impl<'a, 'r> FromRequest<'a, 'r> for Auth {
|
|||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
||||
let mut cookies = request.guard::<Cookies<'_>>().unwrap();
|
||||
let db = match request.guard::<State<'_, DB>>() {
|
||||
let user_manager = match request.guard::<State<'_, user::Manager>>() {
|
||||
Outcome::Success(d) => d,
|
||||
_ => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
|
||||
if let Some(u) = cookies.get_private(dto::COOKIE_SESSION) {
|
||||
let exists = match user::exists(db.deref().deref(), u.value()) {
|
||||
let exists = match user_manager.exists(u.value()) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
|
@ -150,8 +143,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for Auth {
|
|||
password: Some(password),
|
||||
}) = Basic::from_str(auth_header_string.trim_start_matches("Basic "))
|
||||
{
|
||||
if user::auth(db.deref().deref(), &username, &password).unwrap_or(false) {
|
||||
let is_admin = match user::is_admin(db.deref().deref(), &username) {
|
||||
if user_manager.auth(&username, &password).unwrap_or(false) {
|
||||
let is_admin = match user_manager.is_admin(&username) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
|
@ -175,16 +168,16 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminRights {
|
|||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
||||
let db = request.guard::<State<'_, DB>>()?;
|
||||
let user_manager = request.guard::<State<'_, user::Manager>>()?;
|
||||
|
||||
match user::count(&db) {
|
||||
match user_manager.count() {
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
Ok(0) => return Outcome::Success(AdminRights { auth: None }),
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let auth = request.guard::<Auth>()?;
|
||||
match user::is_admin(&db, &auth.username) {
|
||||
match user_manager.is_admin(&auth.username) {
|
||||
Err(_) => Outcome::Failure((Status::InternalServerError, ())),
|
||||
Ok(true) => Outcome::Success(AdminRights { auth: Some(auth) }),
|
||||
Ok(false) => Outcome::Failure((Status::Forbidden, ())),
|
||||
|
@ -223,24 +216,27 @@ fn version() -> Json<dto::Version> {
|
|||
}
|
||||
|
||||
#[get("/initial_setup")]
|
||||
fn initial_setup(db: State<'_, DB>) -> Result<Json<dto::InitialSetup>> {
|
||||
fn initial_setup(user_manager: State<'_, user::Manager>) -> Result<Json<dto::InitialSetup>> {
|
||||
let initial_setup = dto::InitialSetup {
|
||||
has_any_users: user::count(&db)? > 0,
|
||||
has_any_users: user_manager.count()? > 0,
|
||||
};
|
||||
Ok(Json(initial_setup))
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
fn get_settings(db: State<'_, DB>, _admin_rights: AdminRights) -> Result<Json<Config>> {
|
||||
let config = config::read(&db)?;
|
||||
fn get_settings(
|
||||
config_manager: State<'_, config::Manager>,
|
||||
_admin_rights: AdminRights,
|
||||
) -> Result<Json<config::Config>> {
|
||||
let config = config_manager.read()?;
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
#[put("/settings", data = "<config>")]
|
||||
fn put_settings(
|
||||
db: State<'_, DB>,
|
||||
config_manager: State<'_, config::Manager>,
|
||||
admin_rights: AdminRights,
|
||||
config: Json<Config>,
|
||||
config: Json<config::Config>,
|
||||
) -> Result<(), APIError> {
|
||||
// Do not let users remove their own admin rights
|
||||
if let Some(auth) = &admin_rights.auth {
|
||||
|
@ -253,19 +249,26 @@ fn put_settings(
|
|||
}
|
||||
}
|
||||
|
||||
config::amend(&db, &config)?;
|
||||
config_manager.amend(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/preferences")]
|
||||
fn get_preferences(db: State<'_, DB>, auth: Auth) -> Result<Json<Preferences>> {
|
||||
let preferences = config::read_preferences(&db, &auth.username)?;
|
||||
fn get_preferences(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<user::Preferences>> {
|
||||
let preferences = user_manager.read_preferences(&auth.username)?;
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
#[put("/preferences", data = "<preferences>")]
|
||||
fn put_preferences(db: State<'_, DB>, auth: Auth, preferences: Json<Preferences>) -> Result<()> {
|
||||
config::write_preferences(&db, &auth.username, &preferences)?;
|
||||
fn put_preferences(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
auth: Auth,
|
||||
preferences: Json<user::Preferences>,
|
||||
) -> Result<()> {
|
||||
user_manager.write_preferences(&auth.username, &preferences)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -277,85 +280,85 @@ fn trigger_index(index: State<'_, Index>, _admin_rights: AdminRights) -> Result<
|
|||
|
||||
#[post("/auth", data = "<credentials>")]
|
||||
fn auth(
|
||||
db: State<'_, DB>,
|
||||
user_manager: State<'_, user::Manager>,
|
||||
credentials: Json<dto::AuthCredentials>,
|
||||
mut cookies: Cookies<'_>,
|
||||
) -> std::result::Result<(), APIError> {
|
||||
if !user::auth(&db, &credentials.username, &credentials.password)? {
|
||||
if !user_manager.auth(&credentials.username, &credentials.password)? {
|
||||
return Err(APIError::IncorrectCredentials);
|
||||
}
|
||||
let is_admin = user::is_admin(&db, &credentials.username)?;
|
||||
let is_admin = user_manager.is_admin(&credentials.username)?;
|
||||
add_session_cookies(&mut cookies, &credentials.username, is_admin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/browse")]
|
||||
fn browse_root(db: State<'_, DB>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index::browse(db.deref().deref(), &PathBuf::new())?;
|
||||
fn browse_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index.browse(&Path::new(""))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/browse/<path>")]
|
||||
fn browse(
|
||||
db: State<'_, DB>,
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = index::browse(db.deref().deref(), &path.into() as &PathBuf)?;
|
||||
let result = index.browse(&path.into() as &PathBuf)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/flatten")]
|
||||
fn flatten_root(db: State<'_, DB>, _auth: Auth) -> Result<Json<Vec<index::Song>>> {
|
||||
let result = index::flatten(db.deref().deref(), &PathBuf::new())?;
|
||||
fn flatten_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>> {
|
||||
let result = index.flatten(&PathBuf::new())?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/flatten/<path>")]
|
||||
fn flatten(
|
||||
db: State<'_, DB>,
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let result = index::flatten(db.deref().deref(), &path.into() as &PathBuf)?;
|
||||
let result = index.flatten(&path.into() as &PathBuf)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/random")]
|
||||
fn random(db: State<'_, DB>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index::get_random_albums(db.deref().deref(), 20)?;
|
||||
fn random(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index.get_random_albums(20)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/recent")]
|
||||
fn recent(db: State<'_, DB>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index::get_recent_albums(db.deref().deref(), 20)?;
|
||||
fn recent(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index.get_recent_albums(20)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search")]
|
||||
fn search_root(db: State<'_, DB>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index::search(db.deref().deref(), "")?;
|
||||
fn search_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index.search("")?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search/<query>")]
|
||||
fn search(
|
||||
db: State<'_, DB>,
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
query: String,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index::search(db.deref().deref(), &query)?;
|
||||
let result = index.search(&query)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/audio/<path>")]
|
||||
fn audio(
|
||||
db: State<'_, DB>,
|
||||
vfs_manager: State<'_, vfs::Manager>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<serve::RangeResponder<File>, APIError> {
|
||||
let vfs = db.get_vfs()?;
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let real_path = vfs
|
||||
.virtual_to_real(&path.into() as &PathBuf)
|
||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||
|
@ -365,26 +368,29 @@ fn audio(
|
|||
|
||||
#[get("/thumbnail/<path>?<pad>")]
|
||||
fn thumbnail(
|
||||
db: State<'_, DB>,
|
||||
thumbnails_manager: State<'_, ThumbnailsManager>,
|
||||
vfs_manager: State<'_, vfs::Manager>,
|
||||
thumbnail_manager: State<'_, thumbnail::Manager>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
pad: Option<bool>,
|
||||
) -> Result<File, APIError> {
|
||||
let vfs = db.get_vfs()?;
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let image_path = vfs
|
||||
.virtual_to_real(&path.into() as &PathBuf)
|
||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||
let mut options = ThumbnailOptions::default();
|
||||
let mut options = thumbnail::Options::default();
|
||||
options.pad_to_square = pad.unwrap_or(options.pad_to_square);
|
||||
let thumbnail_path = thumbnails_manager.get_thumbnail(&image_path, &options)?;
|
||||
let thumbnail_path = thumbnail_manager.get_thumbnail(&image_path, &options)?;
|
||||
let file = File::open(thumbnail_path).map_err(|_| APIError::Unspecified)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[get("/playlists")]
|
||||
fn list_playlists(db: State<'_, DB>, auth: Auth) -> Result<Json<Vec<dto::ListPlaylistsEntry>>> {
|
||||
let playlist_names = playlist::list_playlists(&auth.username, db.deref().deref())?;
|
||||
fn list_playlists(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>> {
|
||||
let playlist_names = playlist_manager.list_playlists(&auth.username)?;
|
||||
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
|
||||
.into_iter()
|
||||
.map(|p| dto::ListPlaylistsEntry { name: p })
|
||||
|
@ -395,55 +401,69 @@ fn list_playlists(db: State<'_, DB>, auth: Auth) -> Result<Json<Vec<dto::ListPla
|
|||
|
||||
#[put("/playlist/<name>", data = "<playlist>")]
|
||||
fn save_playlist(
|
||||
db: State<'_, DB>,
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
playlist: Json<dto::SavePlaylistInput>,
|
||||
) -> Result<()> {
|
||||
playlist::save_playlist(&name, &auth.username, &playlist.tracks, db.deref().deref())?;
|
||||
playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/playlist/<name>")]
|
||||
fn read_playlist(
|
||||
db: State<'_, DB>,
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let songs = playlist::read_playlist(&name, &auth.username, db.deref().deref())?;
|
||||
let songs = playlist_manager.read_playlist(&name, &auth.username)?;
|
||||
Ok(Json(songs))
|
||||
}
|
||||
|
||||
#[delete("/playlist/<name>")]
|
||||
fn delete_playlist(db: State<'_, DB>, auth: Auth, name: String) -> Result<(), APIError> {
|
||||
playlist::delete_playlist(&name, &auth.username, db.deref().deref())?;
|
||||
fn delete_playlist(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
) -> Result<(), APIError> {
|
||||
playlist_manager.delete_playlist(&name, &auth.username)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[put("/lastfm/now_playing/<path>")]
|
||||
fn lastfm_now_playing(db: State<'_, DB>, auth: Auth, path: VFSPathBuf) -> Result<()> {
|
||||
if user::is_lastfm_linked(db.deref().deref(), &auth.username) {
|
||||
lastfm::now_playing(db.deref().deref(), &auth.username, &path.into() as &PathBuf)?;
|
||||
fn lastfm_now_playing(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<()> {
|
||||
if user_manager.is_lastfm_linked(&auth.username) {
|
||||
lastfm_manager.now_playing(&auth.username, &path.into() as &PathBuf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/lastfm/scrobble/<path>")]
|
||||
fn lastfm_scrobble(db: State<'_, DB>, auth: Auth, path: VFSPathBuf) -> Result<()> {
|
||||
if user::is_lastfm_linked(db.deref().deref(), &auth.username) {
|
||||
lastfm::scrobble(db.deref().deref(), &auth.username, &path.into() as &PathBuf)?;
|
||||
fn lastfm_scrobble(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<()> {
|
||||
if user_manager.is_lastfm_linked(&auth.username) {
|
||||
lastfm_manager.scrobble(&auth.username, &path.into() as &PathBuf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/lastfm/link?<token>&<content>")]
|
||||
fn lastfm_link(
|
||||
db: State<'_, DB>,
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
token: String,
|
||||
content: String,
|
||||
) -> Result<Html<String>> {
|
||||
lastfm::link(db.deref().deref(), &auth.username, &token)?;
|
||||
lastfm_manager.link(&auth.username, &token)?;
|
||||
|
||||
// Percent decode
|
||||
let base64_content = RawStr::from_str(&content).percent_decode()?;
|
||||
|
@ -458,7 +478,7 @@ fn lastfm_link(
|
|||
}
|
||||
|
||||
#[delete("/lastfm/link")]
|
||||
fn lastfm_unlink(db: State<'_, DB>, auth: Auth) -> Result<()> {
|
||||
lastfm::unlink(db.deref().deref(), &auth.username)?;
|
||||
fn lastfm_unlink(lastfm_manager: State<'_, lastfm::Manager>, auth: Auth) -> Result<()> {
|
||||
lastfm_manager.unlink(&auth.username)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -28,7 +28,12 @@ pub fn get_server(context: service::Context) -> Result<rocket::Rocket> {
|
|||
Ok(rocket::custom(config)
|
||||
.manage(context.db)
|
||||
.manage(context.index)
|
||||
.manage(context.thumbnails_manager)
|
||||
.manage(context.config_manager)
|
||||
.manage(context.lastfm_manager)
|
||||
.manage(context.playlist_manager)
|
||||
.manage(context.thumbnail_manager)
|
||||
.manage(context.user_manager)
|
||||
.manage(context.vfs_manager)
|
||||
.mount(&context.api_url, api::get_routes())
|
||||
.mount(
|
||||
&context.swagger_url,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::index;
|
||||
use crate::app::index;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use http::StatusCode;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::index;
|
||||
use crate::app::index;
|
||||
use crate::service::test::{add_trailing_slash, constants::*, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
|
|
|
@ -17,22 +17,12 @@ mod settings;
|
|||
mod swagger;
|
||||
mod web;
|
||||
|
||||
use crate::app::{config, index, vfs};
|
||||
use crate::service::test::constants::*;
|
||||
use crate::{config, index, vfs};
|
||||
|
||||
#[cfg(feature = "service-rocket")]
|
||||
pub use crate::service::rocket::test::ServiceType;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! test_name {
|
||||
() => {{
|
||||
let file_name = file!();
|
||||
let file_name = file_name.replace("/", "-");
|
||||
let file_name = file_name.replace("\\", "-");
|
||||
format!("{}-line-{}", file_name, line!())
|
||||
}};
|
||||
}
|
||||
|
||||
pub trait TestService {
|
||||
fn new(test_name: &str) -> Self;
|
||||
fn request_builder(&self) -> &protocol::RequestBuilder;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::index;
|
||||
use crate::app::index;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::config;
|
||||
use crate::app::user;
|
||||
use crate::service::test::{ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
|
@ -19,7 +19,7 @@ fn test_get_preferences_golden_path() {
|
|||
service.login();
|
||||
|
||||
let request = service.request_builder().get_preferences();
|
||||
let response = service.fetch_json::<_, config::Preferences>(&request);
|
||||
let response = service.fetch_json::<_, user::Preferences>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
|
@ -28,7 +28,7 @@ fn test_put_preferences_requires_auth() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_preferences(config::Preferences::default());
|
||||
.put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ fn test_put_preferences_golden_path() {
|
|||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_preferences(config::Preferences::default());
|
||||
.put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ use http::{method::Method, Request};
|
|||
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config;
|
||||
use crate::app::{config, user};
|
||||
use crate::service::dto;
|
||||
|
||||
pub struct RequestBuilder {}
|
||||
|
@ -80,10 +80,7 @@ impl RequestBuilder {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put_preferences(
|
||||
&self,
|
||||
preferences: config::Preferences,
|
||||
) -> Request<config::Preferences> {
|
||||
pub fn put_preferences(&self, preferences: user::Preferences) -> Request<user::Preferences> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/preferences")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::config;
|
||||
use crate::app::config;
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
|
|
9
src/test.rs
Normal file
9
src/test.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
#[macro_export]
|
||||
macro_rules! test_name {
|
||||
() => {{
|
||||
let file_name = file!();
|
||||
let file_name = file_name.replace("/", "-");
|
||||
let file_name = file_name.replace("\\", "-");
|
||||
format!("{}-line-{}", file_name, line!())
|
||||
}};
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
use anyhow::*;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageOutputFormat};
|
||||
use std::cmp;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs::{self, File};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::*;
|
||||
|
||||
use crate::artwork;
|
||||
|
||||
pub struct ThumbnailsManager {
|
||||
thumbnails_dir_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ThumbnailsManager {
|
||||
pub fn new(thumbnails_dir_path: PathBuf) -> ThumbnailsManager {
|
||||
ThumbnailsManager {
|
||||
thumbnails_dir_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_directory(&self) -> &Path {
|
||||
&self.thumbnails_dir_path
|
||||
}
|
||||
|
||||
pub fn get_thumbnail(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
thumbnailoptions: &ThumbnailOptions,
|
||||
) -> Result<PathBuf> {
|
||||
match self.retrieve_thumbnail(image_path, thumbnailoptions) {
|
||||
Some(path) => Ok(path),
|
||||
None => self.create_thumbnail(image_path, thumbnailoptions),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_thumbnail_path(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
thumbnailoptions: &ThumbnailOptions,
|
||||
) -> PathBuf {
|
||||
let hash = hash(image_path, thumbnailoptions);
|
||||
let mut thumbnail_path = self.thumbnails_dir_path.clone();
|
||||
thumbnail_path.push(format!("{}.jpg", hash.to_string()));
|
||||
thumbnail_path
|
||||
}
|
||||
|
||||
fn retrieve_thumbnail(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
thumbnailoptions: &ThumbnailOptions,
|
||||
) -> Option<PathBuf> {
|
||||
let path = self.get_thumbnail_path(image_path, thumbnailoptions);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn create_thumbnail(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
thumbnailoptions: &ThumbnailOptions,
|
||||
) -> Result<PathBuf> {
|
||||
let thumbnail = generate_thumbnail(image_path, thumbnailoptions)?;
|
||||
let quality = 80;
|
||||
|
||||
fs::create_dir_all(&self.thumbnails_dir_path)?;
|
||||
let path = self.get_thumbnail_path(image_path, thumbnailoptions);
|
||||
let mut out_file = File::create(&path)?;
|
||||
thumbnail.write_to(&mut out_file, ImageOutputFormat::Jpeg(quality))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn hash(path: &Path, thumbnailoptions: &ThumbnailOptions) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
thumbnailoptions.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn generate_thumbnail(
|
||||
image_path: &Path,
|
||||
thumbnailoptions: &ThumbnailOptions,
|
||||
) -> Result<DynamicImage> {
|
||||
let source_image = artwork::read(image_path)?;
|
||||
let (source_width, source_height) = source_image.dimensions();
|
||||
let largest_dimension = cmp::max(source_width, source_height);
|
||||
let out_dimension = cmp::min(thumbnailoptions.max_dimension, largest_dimension);
|
||||
|
||||
let source_aspect_ratio: f32 = source_width as f32 / source_height as f32;
|
||||
let is_almost_square = source_aspect_ratio > 0.8 && source_aspect_ratio < 1.2;
|
||||
|
||||
let mut final_image;
|
||||
if is_almost_square && thumbnailoptions.resize_if_almost_square {
|
||||
final_image = source_image.resize_exact(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
} else if thumbnailoptions.pad_to_square {
|
||||
let scaled_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
let (scaled_width, scaled_height) = scaled_image.dimensions();
|
||||
let background = image::Rgb([255, 255 as u8, 255 as u8]);
|
||||
final_image = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(
|
||||
out_dimension,
|
||||
out_dimension,
|
||||
background,
|
||||
));
|
||||
final_image.copy_from(
|
||||
&scaled_image,
|
||||
(out_dimension - scaled_width) / 2,
|
||||
(out_dimension - scaled_height) / 2,
|
||||
)?;
|
||||
} else {
|
||||
final_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
}
|
||||
|
||||
Ok(final_image)
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash)]
|
||||
pub struct ThumbnailOptions {
|
||||
pub max_dimension: u32,
|
||||
pub resize_if_almost_square: bool,
|
||||
pub pad_to_square: bool,
|
||||
}
|
||||
|
||||
impl Default for ThumbnailOptions {
|
||||
fn default() -> ThumbnailOptions {
|
||||
ThumbnailOptions {
|
||||
max_dimension: 400,
|
||||
resize_if_almost_square: true,
|
||||
pad_to_square: true,
|
||||
}
|
||||
}
|
||||
}
|
120
src/user.rs
120
src/user.rs
|
@ -1,120 +0,0 @@
|
|||
use anyhow::*;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::users;
|
||||
use crate::db::DB;
|
||||
|
||||
#[derive(Debug, Insertable, Queryable)]
|
||||
#[table_name = "users"]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub admin: i32,
|
||||
}
|
||||
|
||||
const HASH_ITERATIONS: u32 = 10000;
|
||||
|
||||
impl User {
|
||||
pub fn new(name: &str, password: &str) -> Result<User> {
|
||||
let hash = hash_password(password)?;
|
||||
Ok(User {
|
||||
name: name.to_owned(),
|
||||
password_hash: hash,
|
||||
admin: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
match pbkdf2::pbkdf2_simple(password, HASH_ITERATIONS) {
|
||||
Ok(hash) => Ok(hash),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(password_hash: &str, attempted_password: &str) -> bool {
|
||||
pbkdf2::pbkdf2_check(attempted_password, password_hash).is_ok()
|
||||
}
|
||||
|
||||
pub fn auth(db: &DB, username: &str, password: &str) -> Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
match users
|
||||
.select(password_hash)
|
||||
.filter(name.eq(username))
|
||||
.get_result(&connection)
|
||||
{
|
||||
Err(diesel::result::Error::NotFound) => Ok(false),
|
||||
Ok(hash) => {
|
||||
let hash: String = hash;
|
||||
Ok(verify_password(&hash, password))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count(db: &DB) -> Result<i64> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
let count = users.count().get_result(&connection)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn exists(db: &DB, username: &str) -> Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
let results: Vec<String> = users
|
||||
.select(name)
|
||||
.filter(name.eq(username))
|
||||
.get_results(&connection)?;
|
||||
Ok(results.len() > 0)
|
||||
}
|
||||
|
||||
pub fn is_admin(db: &DB, username: &str) -> Result<bool> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
let is_admin: i32 = users
|
||||
.filter(name.eq(username))
|
||||
.select(admin)
|
||||
.get_result(&connection)?;
|
||||
Ok(is_admin != 0)
|
||||
}
|
||||
|
||||
pub fn lastfm_link(db: &DB, username: &str, lastfm_login: &str, session_key: &str) -> Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((
|
||||
lastfm_username.eq(lastfm_login),
|
||||
lastfm_session_key.eq(session_key),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_lastfm_session_key(db: &DB, username: &str) -> Result<String> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
let token = users
|
||||
.filter(name.eq(username))
|
||||
.select(lastfm_session_key)
|
||||
.get_result(&connection)?;
|
||||
match token {
|
||||
Some(t) => Ok(t),
|
||||
_ => Err(anyhow!("Missing LastFM credentials")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_lastfm_linked(db: &DB, username: &str) -> bool {
|
||||
get_lastfm_session_key(db, username).is_ok()
|
||||
}
|
||||
|
||||
pub fn lastfm_unlink(db: &DB, username: &str) -> Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((lastfm_session_key.eq(""), lastfm_username.eq("")))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
132
src/vfs.rs
132
src/vfs.rs
|
@ -1,132 +0,0 @@
|
|||
use anyhow::*;
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::db::mount_points;
|
||||
use crate::db::DB;
|
||||
|
||||
pub trait VFSSource {
|
||||
fn get_vfs(&self) -> Result<VFS>;
|
||||
}
|
||||
|
||||
impl VFSSource for DB {
|
||||
fn get_vfs(&self) -> Result<VFS> {
|
||||
use self::mount_points::dsl::*;
|
||||
let mut vfs = VFS::new();
|
||||
let connection = self.connect()?;
|
||||
let points: Vec<MountPoint> = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
for point in points {
|
||||
vfs.mount(&Path::new(&point.source), &point.name)?;
|
||||
}
|
||||
Ok(vfs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "mount_points"]
|
||||
pub struct MountPoint {
|
||||
pub source: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub struct VFS {
|
||||
mount_points: HashMap<String, PathBuf>,
|
||||
}
|
||||
|
||||
impl VFS {
|
||||
pub fn new() -> VFS {
|
||||
VFS {
|
||||
mount_points: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount(&mut self, real_path: &Path, name: &str) -> Result<()> {
|
||||
self.mount_points
|
||||
.insert(name.to_owned(), real_path.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn real_to_virtual<P: AsRef<Path>>(&self, real_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
if let Ok(p) = real_path.as_ref().strip_prefix(target) {
|
||||
let mount_path = Path::new(&name);
|
||||
return if p.components().count() == 0 {
|
||||
Ok(mount_path.to_path_buf())
|
||||
} else {
|
||||
Ok(mount_path.join(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
bail!("Real path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn virtual_to_real<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
let mount_path = Path::new(&name);
|
||||
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
|
||||
return if p.components().count() == 0 {
|
||||
Ok(target.clone())
|
||||
} else {
|
||||
Ok(target.join(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
bail!("Virtual path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn get_mount_points(&self) -> &HashMap<String, PathBuf> {
|
||||
&self.mount_points
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_virtual_to_real() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("test_dir");
|
||||
correct_path.push("somewhere");
|
||||
correct_path.push("something.png");
|
||||
|
||||
let mut virtual_path = PathBuf::new();
|
||||
virtual_path.push("root");
|
||||
virtual_path.push("somewhere");
|
||||
virtual_path.push("something.png");
|
||||
|
||||
let found_path = vfs.virtual_to_real(virtual_path.as_path()).unwrap();
|
||||
assert!(found_path.to_str() == correct_path.to_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_virtual_to_real_no_trail() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
let correct_path = Path::new("test_dir");
|
||||
let found_path = vfs.virtual_to_real(Path::new("root")).unwrap();
|
||||
assert!(found_path.to_str() == correct_path.to_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_real_to_virtual() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("root");
|
||||
correct_path.push("somewhere");
|
||||
correct_path.push("something.png");
|
||||
|
||||
let mut real_path = PathBuf::new();
|
||||
real_path.push("test_dir");
|
||||
real_path.push("somewhere");
|
||||
real_path.push("something.png");
|
||||
|
||||
let found_path = vfs.real_to_virtual(real_path.as_path()).unwrap();
|
||||
assert!(found_path == correct_path);
|
||||
}
|
Loading…
Add table
Reference in a new issue