Diesel -> SQLx

This commit is contained in:
Antoine Gersant 2024-07-13 01:20:27 -07:00
parent 138eacc9fc
commit 12a9f2ec3c
76 changed files with 2482 additions and 2084 deletions
.env.gitignoreCargo.lockCargo.tomldiesel.toml
docs
migrations
201706250006_init
201706250228_directories_date_added
201706272129_users_table
201706272304_misc_settings_table
201706272313_ddns_config_table
201706272327_mount_points_table
201707091522_playlists_tables
20170929203228_add_prefix_url
20171015224223_add_song_duration
20180303211100_add_last_fm_credentials
2019-08-08-042731_blob_auth_secret
2019-09-28-231910_pbkdf2_simple
2020-01-08-231420_add_theme
2020-11-25-174000_remove_prefix_url
2021-05-01-011426_add_lyricist
src
update_db_schema.bat

1
.env Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=sqlite:./src/db/schema.sqlite

2
.gitignore vendored
View file

@ -9,6 +9,8 @@ TestConfig.toml
# Runtime artifacts
*.sqlite
**/*.sqlite-shm
**/*.sqlite-wal
polaris.log
polaris.pid
/thumbnails

917
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,6 @@ edition = "2021"
build = "build.rs"
[features]
default = ["bundle-sqlite"]
bundle-sqlite = ["libsqlite3-sys"]
ui = ["native-windows-gui", "native-windows-derive"]
[dependencies]
@ -18,16 +16,11 @@ ape = "0.5"
base64 = "0.21"
branca = "0.10.1"
crossbeam-channel = "0.5"
diesel_migrations = { version = "2.0", features = ["sqlite"] }
futures-util = { version = "0.3" }
getopts = "0.2.21"
http = "0.2.8"
id3 = "1.7.0"
lewton = "0.10.2"
libsqlite3-sys = { version = "0.26", features = [
"bundled",
"bundled-windows",
], optional = true }
log = "0.4.17"
metaflac = "0.2.5"
mp3-duration = "0.1.10"
@ -44,19 +37,16 @@ serde = { version = "1.0.147", features = ["derive"] }
serde_derive = "1.0.147"
serde_json = "1.0.87"
simplelog = "0.12.0"
sqlx = { version = "0.7.4", features = ["migrate", "runtime-tokio", "sqlite"] }
thiserror = "1.0.37"
tokio = { version = "1.38", features = ["macros", "rt-multi-thread"] }
toml = "0.7"
ureq = "2.7"
url = "2.3"
[dependencies.diesel]
version = "2.0.2"
default_features = false
features = ["libsqlite3-sys", "r2d2", "sqlite"]
[dependencies.image]
version = "0.24.4"
default_features = false
default-features = false
features = ["bmp", "gif", "jpeg", "png"]
[target.'cfg(windows)'.dependencies]
@ -81,3 +71,6 @@ winres = "0.1"
actix-test = "0.1.0"
headers = "0.3"
fs_extra = "1.2.0"
[profile.dev.package.sqlx-macros]
opt-level = 3

View file

@ -1,2 +0,0 @@
[print_schema]
file = "src/db/schema.rs"

View file

@ -8,8 +8,3 @@
- Input a user-facing version name (eg: **0.13.0**)
- Click the **Run workflow** button
- After CI completes, move the release from Draft to Published
## How to change the database schema
- Add a new folder under `migrations` following the existing pattern
- Run `update_db_schema.bat`

View file

@ -1,2 +0,0 @@
DROP TABLE directories;
DROP TABLE songs;

View file

@ -1,25 +0,0 @@
CREATE TABLE directories (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT,
artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);
CREATE TABLE songs (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
title TEXT,
artist TEXT,
album_artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);

View file

@ -1,15 +0,0 @@
CREATE TEMPORARY TABLE directories_backup(id, path, parent, artist, year, album, artwork);
INSERT INTO directories_backup SELECT id, path, parent, artist, year, album, artwork FROM directories;
DROP TABLE directories;
CREATE TABLE directories (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT,
artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);
INSERT INTO directories SELECT * FROM directories_backup;
DROP TABLE directories_backup;

View file

@ -1 +0,0 @@
ALTER TABLE directories ADD COLUMN date_added INTEGER DEFAULT 0 NOT NULL;

View file

@ -1 +0,0 @@
DROP TABLE users;

View file

@ -1,8 +0,0 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_salt BLOB NOT NULL,
password_hash BLOB NOT NULL,
admin INTEGER NOT NULL,
UNIQUE(name)
);

View file

@ -1 +0,0 @@
DROP TABLE misc_settings;

View file

@ -1,7 +0,0 @@
CREATE TABLE misc_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret TEXT NOT NULL,
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL
);
INSERT INTO misc_settings (id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern) VALUES (0, hex(randomblob(64)), 1800, "Folder.(jpeg|jpg|png)");

View file

@ -1 +0,0 @@
DROP TABLE ddns_config;

View file

@ -1,8 +0,0 @@
CREATE TABLE ddns_config (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
host TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL
);
INSERT INTO ddns_config (id, host, username, password) VALUES (0, "", "", "");

View file

@ -1 +0,0 @@
DROP TABLE mount_points;

View file

@ -1,6 +0,0 @@
CREATE TABLE mount_points (
id INTEGER PRIMARY KEY NOT NULL,
source TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE(name)
);

View file

@ -1,2 +0,0 @@
DROP TABLE playlists;
DROP TABLE playlist_songs;

View file

@ -1,16 +0,0 @@
CREATE TABLE playlists (
id INTEGER PRIMARY KEY NOT NULL,
owner INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY(owner) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(owner, name) ON CONFLICT REPLACE
);
CREATE TABLE playlist_songs (
id INTEGER PRIMARY KEY NOT NULL,
playlist INTEGER NOT NULL,
path TEXT NOT NULL,
ordering INTEGER NOT NULL,
FOREIGN KEY(playlist) REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE(playlist, ordering) ON CONFLICT REPLACE
);

View file

@ -1,11 +0,0 @@
CREATE TEMPORARY TABLE misc_settings_backup(id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern);
INSERT INTO misc_settings_backup SELECT id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern FROM misc_settings;
DROP TABLE misc_settings;
CREATE TABLE misc_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret TEXT NOT NULL,
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL
);
INSERT INTO misc_settings SELECT * FROM misc_settings_backup;
DROP TABLE misc_settings_backup;

View file

@ -1 +0,0 @@
ALTER TABLE misc_settings ADD COLUMN prefix_url TEXT NOT NULL DEFAULT "";

View file

@ -1,19 +0,0 @@
CREATE TEMPORARY TABLE songs_backup(id, path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork);
INSERT INTO songs_backup SELECT id, path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork FROM songs;
DROP TABLE songs;
CREATE TABLE songs (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
title TEXT,
artist TEXT,
album_artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);
INSERT INTO songs SELECT * FROM songs_backup;
DROP TABLE songs_backup;

View file

@ -1 +0,0 @@
ALTER TABLE songs ADD COLUMN duration INTEGER;

View file

@ -1,13 +0,0 @@
CREATE TEMPORARY TABLE users_backup(id, name, password_salt, password_hash, admin);
INSERT INTO users_backup SELECT id, name, password_salt, password_hash, admin FROM users;
DROP TABLE users;
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_salt BLOB NOT NULL,
password_hash BLOB NOT NULL,
admin INTEGER NOT NULL,
UNIQUE(name)
);
INSERT INTO users SELECT * FROM users_backup;
DROP TABLE users_backup;

View file

@ -1,2 +0,0 @@
ALTER TABLE users ADD COLUMN lastfm_username TEXT;
ALTER TABLE users ADD COLUMN lastfm_session_key TEXT;

View file

@ -1,15 +0,0 @@
CREATE TEMPORARY TABLE misc_settings_backup(id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url);
INSERT INTO misc_settings_backup
SELECT id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url
FROM misc_settings;
DROP TABLE misc_settings;
CREATE TABLE misc_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret BLOB NOT NULL DEFAULT (hex(randomblob(32))),
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL,
prefix_url TEXT NOT NULL DEFAULT ""
);
INSERT INTO misc_settings(id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url)
SELECT * FROM misc_settings_backup;
DROP TABLE misc_settings_backup;

View file

@ -1,15 +0,0 @@
CREATE TEMPORARY TABLE misc_settings_backup(id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url);
INSERT INTO misc_settings_backup
SELECT id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url
FROM misc_settings;
DROP TABLE misc_settings;
CREATE TABLE misc_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret BLOB NOT NULL DEFAULT (randomblob(32)),
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL,
prefix_url TEXT NOT NULL DEFAULT ""
);
INSERT INTO misc_settings(id, index_sleep_duration_seconds, index_album_art_pattern, prefix_url)
SELECT * FROM misc_settings_backup;
DROP TABLE misc_settings_backup;

View file

@ -1,11 +0,0 @@
DROP TABLE users;
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_salt BLOB NOT NULL,
password_hash BLOB NOT NULL,
admin INTEGER NOT NULL,
lastfm_username TEXT,
lastfm_session_key TEXT,
UNIQUE(name)
);

View file

@ -1,10 +0,0 @@
DROP TABLE users;
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL,
lastfm_username TEXT,
lastfm_session_key TEXT,
UNIQUE(name)
);

View file

@ -1,14 +0,0 @@
CREATE TEMPORARY TABLE users_backup(id, name, password_hash, admin, lastfm_username, lastfm_session_key);
INSERT INTO users_backup SELECT id, name, password_hash, admin, lastfm_username, lastfm_session_key FROM users;
DROP TABLE users;
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL,
lastfm_username TEXT,
lastfm_session_key TEXT,
UNIQUE(name)
);
INSERT INTO users SELECT * FROM users_backup;
DROP TABLE users_backup;

View file

@ -1,2 +0,0 @@
ALTER TABLE users ADD COLUMN web_theme_base TEXT;
ALTER TABLE users ADD COLUMN web_theme_accent TEXT;

View file

@ -1 +0,0 @@
ALTER TABLE misc_settings ADD COLUMN prefix_url TEXT NOT NULL DEFAULT "";

View file

@ -1,11 +0,0 @@
CREATE TEMPORARY TABLE misc_settings_backup(id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern);
INSERT INTO misc_settings_backup SELECT id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern FROM misc_settings;
DROP TABLE misc_settings;
CREATE TABLE misc_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret BLOB NOT NULL DEFAULT (randomblob(32)),
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL
);
INSERT INTO misc_settings SELECT * FROM misc_settings_backup;
DROP TABLE misc_settings_backup;

View file

@ -1,20 +0,0 @@
CREATE TEMPORARY TABLE songs_backup(id, path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork, duration);
INSERT INTO songs_backup SELECT id, path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork, duration FROM songs;
DROP TABLE songs;
CREATE TABLE songs (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
title TEXT,
artist TEXT,
album_artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
duration INTEGER,
UNIQUE(path) ON CONFLICT REPLACE
);
INSERT INTO songs SELECT * FROM songs_backup;
DROP TABLE songs_backup;

View file

@ -1,4 +0,0 @@
ALTER TABLE songs ADD COLUMN lyricist TEXT;
ALTER TABLE songs ADD COLUMN composer TEXT;
ALTER TABLE songs ADD COLUMN genre TEXT;
ALTER TABLE songs ADD COLUMN label TEXT;

View file

@ -32,10 +32,8 @@ pub enum Error {
#[derive(Clone)]
pub struct App {
pub port: u16,
pub auth_secret: settings::AuthSecret,
pub web_dir_path: PathBuf,
pub swagger_dir_path: PathBuf,
pub db: DB,
pub index: index::Index,
pub config_manager: config::Manager,
pub ddns_manager: ddns::Manager,
@ -48,8 +46,8 @@ pub struct App {
}
impl App {
pub fn new(port: u16, paths: Paths) -> Result<Self, Error> {
let db = DB::new(&paths.db_file_path)?;
pub async fn new(port: u16, paths: Paths) -> Result<Self, Error> {
let db = DB::new(&paths.db_file_path).await?;
fs::create_dir_all(&paths.web_dir_path)
.map_err(|e| Error::Io(paths.web_dir_path.clone(), e))?;
fs::create_dir_all(&paths.swagger_dir_path)
@ -61,7 +59,7 @@ impl App {
let vfs_manager = vfs::Manager::new(db.clone());
let settings_manager = settings::Manager::new(db.clone());
let auth_secret = settings_manager.get_auth_secret()?;
let auth_secret = settings_manager.get_auth_secret().await?;
let ddns_manager = ddns::Manager::new(db.clone());
let user_manager = user::Manager::new(db.clone(), auth_secret);
let index = index::Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
@ -77,14 +75,11 @@ impl App {
if let Some(config_path) = paths.config_file_path {
let config = config::Config::from_path(&config_path)?;
config_manager.apply(&config)?;
config_manager.apply(&config).await?;
}
let auth_secret = settings_manager.get_auth_secret()?;
Ok(Self {
port,
auth_secret,
web_dir_path: paths.web_dir_path,
swagger_dir_path: paths.swagger_dir_path,
index,
@ -96,7 +91,6 @@ impl App {
thumbnail_manager,
user_manager,
vfs_manager,
db,
})
}
}

View file

@ -64,28 +64,28 @@ impl Manager {
}
}
pub fn apply(&self, config: &Config) -> Result<(), Error> {
pub async fn apply(&self, config: &Config) -> Result<(), Error> {
if let Some(new_settings) = &config.settings {
self.settings_manager.amend(new_settings)?;
self.settings_manager.amend(new_settings).await?;
}
if let Some(mount_dirs) = &config.mount_dirs {
self.vfs_manager.set_mount_dirs(mount_dirs)?;
self.vfs_manager.set_mount_dirs(mount_dirs).await?;
}
if let Some(ddns_config) = &config.ydns {
self.ddns_manager.set_config(ddns_config)?;
self.ddns_manager.set_config(ddns_config).await?;
}
if let Some(ref users) = config.users {
let old_users: Vec<user::User> = self.user_manager.list()?;
let old_users: Vec<user::User> = self.user_manager.list().await?;
// Delete users that are not in new list
for old_user in old_users
.iter()
.filter(|old_user| !users.iter().any(|u| u.name == old_user.name))
{
self.user_manager.delete(&old_user.name)?;
self.user_manager.delete(&old_user.name).await?;
}
// Insert new users
@ -93,13 +93,17 @@ impl Manager {
.iter()
.filter(|u| !old_users.iter().any(|old_user| old_user.name == u.name))
{
self.user_manager.create(new_user)?;
self.user_manager.create(new_user).await?;
}
// Update users
for user in users {
self.user_manager.set_password(&user.name, &user.password)?;
self.user_manager.set_is_admin(&user.name, user.admin)?;
self.user_manager
.set_password(&user.name, &user.password)
.await?;
self.user_manager
.set_is_admin(&user.name, user.admin)
.await?;
}
}
@ -114,9 +118,9 @@ mod test {
use crate::app::test;
use crate::test_name;
#[test]
fn apply_saves_misc_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn apply_saves_misc_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config {
settings: Some(settings::NewSettings {
album_art_pattern: Some("🖼️\\.jpg".into()),
@ -125,8 +129,8 @@ mod test {
..Default::default()
};
ctx.config_manager.apply(&new_config).unwrap();
let settings = ctx.settings_manager.read().unwrap();
ctx.config_manager.apply(&new_config).await.unwrap();
let settings = ctx.settings_manager.read().await.unwrap();
let new_settings = new_config.settings.unwrap();
assert_eq!(
settings.index_album_art_pattern,
@ -138,9 +142,9 @@ mod test {
);
}
#[test]
fn apply_saves_mount_points() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn apply_saves_mount_points() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config {
mount_dirs: Some(vec![vfs::MountDir {
@ -150,36 +154,37 @@ mod test {
..Default::default()
};
ctx.config_manager.apply(&new_config).unwrap();
let actual_mount_dirs: Vec<vfs::MountDir> = ctx.vfs_manager.mount_dirs().unwrap();
ctx.config_manager.apply(&new_config).await.unwrap();
let actual_mount_dirs: Vec<vfs::MountDir> = ctx.vfs_manager.mount_dirs().await.unwrap();
assert_eq!(actual_mount_dirs, new_config.mount_dirs.unwrap());
}
#[test]
fn apply_saves_ddns_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn apply_saves_ddns_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config {
ydns: Some(ddns::Config {
host: "🐸🐸🐸.ydns.eu".into(),
username: "kfr🐸g".into(),
password: "tasty🐞".into(),
ddns_host: "🐸🐸🐸.ydns.eu".into(),
ddns_username: "kfr🐸g".into(),
ddns_password: "tasty🐞".into(),
}),
..Default::default()
};
ctx.config_manager.apply(&new_config).unwrap();
let actual_ddns = ctx.ddns_manager.config().unwrap();
ctx.config_manager.apply(&new_config).await.unwrap();
let actual_ddns = ctx.ddns_manager.config().await.unwrap();
assert_eq!(actual_ddns, new_config.ydns.unwrap());
}
#[test]
fn apply_can_toggle_admin() {
#[tokio::test]
async fn apply_can_toggle_admin() {
let ctx = test::ContextBuilder::new(test_name!())
.user("Walter", "Tasty🍖", true)
.build();
.build()
.await;
assert!(ctx.user_manager.list().unwrap()[0].is_admin());
assert!(ctx.user_manager.list().await.unwrap()[0].is_admin());
let new_config = Config {
users: Some(vec![user::NewUser {
@ -189,7 +194,7 @@ mod test {
}]),
..Default::default()
};
ctx.config_manager.apply(&new_config).unwrap();
assert!(!ctx.user_manager.list().unwrap()[0].is_admin());
ctx.config_manager.apply(&new_config).await.unwrap();
assert!(!ctx.user_manager.list().await.unwrap()[0].is_admin());
}
}

View file

@ -1,11 +1,9 @@
use base64::prelude::*;
use diesel::prelude::*;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::thread;
use std::time;
use std::time::Duration;
use crate::db::{self, ddns_config, DB};
use crate::db::{self, DB};
const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/";
@ -18,15 +16,14 @@ pub enum Error {
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
}
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Eq, Queryable, Serialize)]
#[diesel(table_name = ddns_config)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct Config {
pub host: String,
pub username: String,
pub password: String,
pub ddns_host: String,
pub ddns_username: String,
pub ddns_password: String,
}
#[derive(Clone)]
@ -39,15 +36,15 @@ impl Manager {
Self { db }
}
fn update_my_ip(&self) -> Result<(), Error> {
let config = self.config()?;
if config.host.is_empty() || config.username.is_empty() {
async fn update_my_ip(&self) -> Result<(), Error> {
let config = self.config().await?;
if config.ddns_host.is_empty() || config.ddns_username.is_empty() {
debug!("Skipping DDNS update because credentials are missing");
return Ok(());
}
let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.host);
let credentials = format!("{}:{}", &config.username, &config.password);
let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.ddns_host);
let credentials = format!("{}:{}", &config.ddns_username, &config.ddns_password);
let response = ureq::get(full_url.as_str())
.set(
"Authorization",
@ -62,40 +59,38 @@ impl Manager {
}
}
pub fn config(&self) -> Result<Config, Error> {
use crate::db::ddns_config::dsl::*;
let mut connection = self.db.connect()?;
Ok(ddns_config
.select((host, username, password))
.get_result(&mut connection)?)
pub async fn config(&self) -> Result<Config, Error> {
Ok(sqlx::query_as!(
Config,
"SELECT ddns_host, ddns_username, ddns_password FROM config"
)
.fetch_one(self.db.connect().await?.as_mut())
.await?)
}
pub fn set_config(&self, new_config: &Config) -> Result<(), Error> {
use crate::db::ddns_config::dsl::*;
let mut connection = self.db.connect()?;
diesel::update(ddns_config)
.set((
host.eq(&new_config.host),
username.eq(&new_config.username),
password.eq(&new_config.password),
))
.execute(&mut connection)?;
pub async fn set_config(&self, new_config: &Config) -> Result<(), Error> {
sqlx::query!(
"UPDATE config SET ddns_host = $1, ddns_username = $2, ddns_password = $3",
new_config.ddns_host,
new_config.ddns_username,
new_config.ddns_password
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn begin_periodic_updates(&self) {
let cloned = self.clone();
std::thread::spawn(move || {
cloned.run();
tokio::spawn({
let ddns = self.clone();
async move {
loop {
if let Err(e) = ddns.update_my_ip().await {
error!("Dynamic DNS update error: {:?}", e);
}
tokio::time::sleep(Duration::from_secs(60 * 30)).await;
}
}
});
}
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));
}
}
}

View file

@ -1,6 +1,7 @@
use log::error;
use std::sync::{Arc, Condvar, Mutex};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use crate::app::{settings, vfs};
use crate::db::DB;
@ -20,7 +21,7 @@ pub struct Index {
db: DB,
vfs_manager: vfs::Manager,
settings_manager: settings::Manager,
pending_reindex: Arc<(Mutex<bool>, Condvar)>,
pending_reindex: Arc<Notify>,
}
impl Index {
@ -29,63 +30,45 @@ impl Index {
db,
vfs_manager,
settings_manager,
pending_reindex: Arc::new((
#[allow(clippy::mutex_atomic)]
Mutex::new(false),
Condvar::new(),
)),
pending_reindex: Arc::new(Notify::new()),
};
let commands_index = index.clone();
std::thread::spawn(move || {
commands_index.process_commands();
tokio::spawn({
let index = index.clone();
async move {
loop {
index.pending_reindex.notified().await;
if let Err(e) = index.update().await {
error!("Error while updating index: {}", e);
}
}
}
});
index
}
pub fn trigger_reindex(&self) {
let (lock, cvar) = &*self.pending_reindex;
let mut pending_reindex = lock.lock().unwrap();
*pending_reindex = true;
cvar.notify_one();
self.pending_reindex.notify_one();
}
pub fn begin_periodic_updates(&self) {
let auto_index = self.clone();
std::thread::spawn(move || {
auto_index.automatic_reindex();
tokio::spawn({
let index = self.clone();
async move {
loop {
index.trigger_reindex();
let sleep_duration = index
.settings_manager
.get_index_sleep_duration()
.await
.unwrap_or_else(|e| {
error!("Could not retrieve index sleep duration: {}", e);
Duration::from_secs(1800)
});
tokio::time::sleep(sleep_duration).await;
}
}
});
}
fn process_commands(&self) {
loop {
{
let (lock, cvar) = &*self.pending_reindex;
let mut pending = lock.lock().unwrap();
while !*pending {
pending = cvar.wait(pending).unwrap();
}
*pending = false;
}
if let Err(e) = self.update() {
error!("Error while updating index: {}", e);
}
}
}
fn automatic_reindex(&self) {
loop {
self.trigger_reindex();
let sleep_duration = self
.settings_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);
}
}
}

View file

@ -1,15 +1,12 @@
use diesel::dsl::sql;
use diesel::prelude::*;
use diesel::sql_types;
use std::path::{Path, PathBuf};
use super::*;
use crate::db::{self, directories, songs};
use crate::db;
#[derive(thiserror::Error, Debug)]
pub enum QueryError {
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error("Song was not found: `{0}`")]
@ -18,25 +15,21 @@ pub enum QueryError {
Vfs(#[from] vfs::Error),
}
sql_function!(
#[aggregate]
fn random() -> Integer;
);
impl Index {
pub fn browse<P>(&self, virtual_path: P) -> Result<Vec<CollectionFile>, QueryError>
pub async 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 mut connection = self.db.connect()?;
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
if virtual_path.as_ref().components().count() == 0 {
// Browse top-level
let real_directories: Vec<Directory> = directories::table
.filter(directories::parent.is_null())
.load(&mut connection)?;
let real_directories =
sqlx::query_as!(Directory, "SELECT * FROM directories WHERE parent IS NULL")
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories
.into_iter()
.filter_map(|d| d.virtualize(&vfs));
@ -46,19 +39,28 @@ impl Index {
let real_path = vfs.virtual_to_real(virtual_path)?;
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(&mut connection)?;
let real_directories = sqlx::query_as!(
Directory,
"SELECT * FROM directories WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
real_path_string
)
.fetch_all(connection.as_mut())
.await?;
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(&mut connection)?;
let real_songs = sqlx::query_as!(
Song,
"SELECT * FROM songs WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
real_path_string
)
.fetch_all(connection.as_mut())
.await?;
let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
output.extend(virtual_songs.map(CollectionFile::Song));
}
@ -66,76 +68,88 @@ impl Index {
Ok(output)
}
pub fn flatten<P>(&self, virtual_path: P) -> Result<Vec<Song>, QueryError>
pub async 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 mut connection = self.db.connect()?;
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
let real_songs: Vec<Song> = if virtual_path.as_ref().parent().is_some() {
let real_songs = if virtual_path.as_ref().parent().is_some() {
let real_path = vfs.virtual_to_real(virtual_path)?;
let song_path_filter = {
let mut path_buf = real_path;
path_buf.push("%");
path_buf.as_path().to_string_lossy().into_owned()
};
songs
.filter(path.like(&song_path_filter))
.order(path)
.load(&mut connection)?
sqlx::query_as!(
Song,
"SELECT * FROM songs WHERE path LIKE $1 ORDER BY path COLLATE NOCASE ASC",
song_path_filter
)
.fetch_all(connection.as_mut())
.await?
} else {
songs.order(path).load(&mut connection)?
sqlx::query_as!(Song, "SELECT * FROM songs ORDER BY path COLLATE NOCASE ASC")
.fetch_all(connection.as_mut())
.await?
};
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>, QueryError> {
use self::directories::dsl::*;
let vfs = self.vfs_manager.get_vfs()?;
let mut connection = self.db.connect()?;
let real_directories: Vec<Directory> = directories
.filter(album.is_not_null())
.limit(count)
.order(random())
.load(&mut connection)?;
pub async fn get_random_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
let real_directories = sqlx::query_as!(
Directory,
"SELECT * FROM directories WHERE album IS NOT NULL ORDER BY RANDOM() DESC LIMIT $1",
count
)
.fetch_all(connection.as_mut())
.await?;
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>, QueryError> {
use self::directories::dsl::*;
let vfs = self.vfs_manager.get_vfs()?;
let mut connection = self.db.connect()?;
let real_directories: Vec<Directory> = directories
.filter(album.is_not_null())
.order(date_added.desc())
.limit(count)
.load(&mut connection)?;
pub async fn get_recent_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
let real_directories = sqlx::query_as!(
Directory,
"SELECT * FROM directories WHERE album IS NOT NULL ORDER BY date_added DESC LIMIT $1",
count
)
.fetch_all(connection.as_mut())
.await?;
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>, QueryError> {
let vfs = self.vfs_manager.get_vfs()?;
let mut connection = self.db.connect()?;
pub async fn search(&self, query: &str) -> Result<Vec<CollectionFile>, QueryError> {
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
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(&mut connection)?;
let real_directories = sqlx::query_as!(
Directory,
"SELECT * FROM directories WHERE path LIKE $1 AND parent NOT LIKE $1",
like_test
)
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories
.into_iter()
@ -146,17 +160,22 @@ impl Index {
// 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(&mut connection)?;
let real_songs = sqlx::query_as!(
Song,
r#"
SELECT * FROM songs
WHERE ( path LIKE $1
OR title LIKE $1
OR album LIKE $1
OR artist LIKE $1
OR album_artist LIKE $1
)
AND parent NOT LIKE $1
"#,
like_test
)
.fetch_all(connection.as_mut())
.await?;
let virtual_songs = real_songs.into_iter().filter_map(|d| d.virtualize(&vfs));
@ -166,17 +185,20 @@ impl Index {
Ok(output)
}
pub fn get_song(&self, virtual_path: &Path) -> Result<Song, QueryError> {
let vfs = self.vfs_manager.get_vfs()?;
let mut connection = self.db.connect()?;
pub async fn get_song(&self, virtual_path: &Path) -> Result<Song, QueryError> {
let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect().await?;
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(&mut connection)?;
let real_song = sqlx::query_as!(
Song,
"SELECT * FROM songs WHERE path = $1",
real_path_string
)
.fetch_one(connection.as_mut())
.await?;
match real_song.virtualize(&vfs) {
Some(s) => Ok(s),

View file

@ -1,32 +1,37 @@
use diesel::prelude::*;
use std::default::Default;
use std::path::{Path, PathBuf};
use super::*;
use crate::app::test;
use crate::db::{directories, songs};
use crate::test_name;
const TEST_MOUNT_NAME: &str = "root";
#[test]
fn update_adds_new_content() {
#[tokio::test]
async fn update_adds_new_content() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().unwrap(); // Validates that subsequent updates don't run into conflicts
ctx.index.update().await.unwrap();
ctx.index.update().await.unwrap(); // Validates that subsequent updates don't run into conflicts
let mut connection = ctx.db.connect().unwrap();
let all_directories: Vec<Directory> = directories::table.load(&mut connection).unwrap();
let all_songs: Vec<Song> = songs::table.load(&mut connection).unwrap();
let mut connection = ctx.db.connect().await.unwrap();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 6);
assert_eq!(all_songs.len(), 13);
}
#[test]
fn update_removes_missing_content() {
#[tokio::test]
async fn update_removes_missing_content() {
let builder = test::ContextBuilder::new(test_name!());
let original_collection_dir: PathBuf = ["test-data", "small-collection"].iter().collect();
@ -42,39 +47,53 @@ fn update_removes_missing_content() {
let ctx = builder
.mount(TEST_MOUNT_NAME, test_collection_dir.to_str().unwrap())
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
{
let mut connection = ctx.db.connect().unwrap();
let all_directories: Vec<Directory> = directories::table.load(&mut connection).unwrap();
let all_songs: Vec<Song> = songs::table.load(&mut connection).unwrap();
let mut connection = ctx.db.connect().await.unwrap();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 6);
assert_eq!(all_songs.len(), 13);
}
let khemmis_directory = test_collection_dir.join("Khemmis");
std::fs::remove_dir_all(khemmis_directory).unwrap();
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
{
let mut connection = ctx.db.connect().unwrap();
let all_directories: Vec<Directory> = directories::table.load(&mut connection).unwrap();
let all_songs: Vec<Song> = songs::table.load(&mut connection).unwrap();
let mut connection = ctx.db.connect().await.unwrap();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 4);
assert_eq!(all_songs.len(), 8);
}
}
#[test]
fn can_browse_top_level() {
#[tokio::test]
async fn can_browse_top_level() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let root_path = Path::new(TEST_MOUNT_NAME);
let files = ctx.index.browse(Path::new("")).unwrap();
let files = ctx.index.browse(Path::new("")).await.unwrap();
assert_eq!(files.len(), 1);
match files[0] {
CollectionFile::Directory(ref d) => assert_eq!(d.path, root_path.to_str().unwrap()),
@ -82,17 +101,18 @@ fn can_browse_top_level() {
}
}
#[test]
fn can_browse_directory() {
#[tokio::test]
async fn can_browse_directory() {
let khemmis_path: PathBuf = [TEST_MOUNT_NAME, "Khemmis"].iter().collect();
let tobokegao_path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao"].iter().collect();
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let files = ctx.index.browse(Path::new(TEST_MOUNT_NAME)).unwrap();
let files = ctx.index.browse(Path::new(TEST_MOUNT_NAME)).await.unwrap();
assert_eq!(files.len(), 2);
match files[0] {
@ -106,73 +126,79 @@ fn can_browse_directory() {
}
}
#[test]
fn can_flatten_root() {
#[tokio::test]
async fn can_flatten_root() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
let songs = ctx.index.flatten(Path::new(TEST_MOUNT_NAME)).unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let songs = ctx.index.flatten(Path::new(TEST_MOUNT_NAME)).await.unwrap();
assert_eq!(songs.len(), 13);
assert_eq!(songs[0].title, Some("Above The Water".to_owned()));
}
#[test]
fn can_flatten_directory() {
#[tokio::test]
async fn can_flatten_directory() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao"].iter().collect();
let songs = ctx.index.flatten(path).unwrap();
let songs = ctx.index.flatten(path).await.unwrap();
assert_eq!(songs.len(), 8);
}
#[test]
fn can_flatten_directory_with_shared_prefix() {
#[tokio::test]
async fn can_flatten_directory_with_shared_prefix() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect(); // Prefix of '(Picnic Remixes)'
let songs = ctx.index.flatten(path).unwrap();
let songs = ctx.index.flatten(path).await.unwrap();
assert_eq!(songs.len(), 7);
}
#[test]
fn can_get_random_albums() {
#[tokio::test]
async fn can_get_random_albums() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
let albums = ctx.index.get_random_albums(1).unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let albums = ctx.index.get_random_albums(1).await.unwrap();
assert_eq!(albums.len(), 1);
}
#[test]
fn can_get_recent_albums() {
#[tokio::test]
async fn can_get_recent_albums() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
ctx.index.update().unwrap();
let albums = ctx.index.get_recent_albums(2).unwrap();
.build()
.await;
ctx.index.update().await.unwrap();
let albums = ctx.index.get_recent_albums(2).await.unwrap();
assert_eq!(albums.len(), 2);
assert!(albums[0].date_added >= albums[1].date_added);
}
#[test]
fn can_get_a_song() {
#[tokio::test]
async fn can_get_a_song() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
let song_virtual_path = picnic_virtual_dir.join("05 - シャーベット (Sherbet).mp3");
let artwork_virtual_path = picnic_virtual_dir.join("Folder.png");
let song = ctx.index.get_song(&song_virtual_path).unwrap();
let song = ctx.index.get_song(&song_virtual_path).await.unwrap();
assert_eq!(song.path, song_virtual_path.to_string_lossy().as_ref());
assert_eq!(song.track_number, Some(5));
assert_eq!(song.disc_number, None);
@ -187,29 +213,31 @@ fn can_get_a_song() {
);
}
#[test]
fn indexes_embedded_artwork() {
#[tokio::test]
async fn indexes_embedded_artwork() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
let song_virtual_path = picnic_virtual_dir.join("07 - なぜ (Why).mp3");
let song = ctx.index.get_song(&song_virtual_path).unwrap();
let song = ctx.index.get_song(&song_virtual_path).await.unwrap();
assert_eq!(
song.artwork,
Some(song_virtual_path.to_string_lossy().into_owned())
);
}
#[test]
fn album_art_pattern_is_case_insensitive() {
#[tokio::test]
async fn album_art_pattern_is_case_insensitive() {
let ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
let patterns = vec!["folder", "FOLDER"];
@ -219,12 +247,13 @@ fn album_art_pattern_is_case_insensitive() {
album_art_pattern: Some(pattern.to_owned()),
..Default::default()
})
.await
.unwrap();
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
let hunted_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let artwork_virtual_path = hunted_virtual_dir.join("Folder.jpg");
let song = &ctx.index.flatten(&hunted_virtual_dir).unwrap()[0];
let song = &ctx.index.flatten(&hunted_virtual_dir).await.unwrap()[0];
assert_eq!(
song.artwork,
Some(artwork_virtual_path.to_string_lossy().into_owned())

View file

@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::app::vfs::VFS;
use crate::db::songs;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CollectionFile {
@ -10,23 +9,22 @@ pub enum CollectionFile {
Song(Song),
}
#[derive(Debug, PartialEq, Eq, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = songs)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Song {
#[serde(skip_serializing, skip_deserializing)]
id: i32,
pub id: i64,
pub path: String,
#[serde(skip_serializing, skip_deserializing)]
pub parent: String,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub title: Option<String>,
pub artist: Option<String>,
pub album_artist: Option<String>,
pub year: Option<i32>,
pub year: Option<i64>,
pub album: Option<String>,
pub artwork: Option<String>,
pub duration: Option<i32>,
pub duration: Option<i64>,
pub lyricist: Option<String>,
pub composer: Option<String>,
pub genre: Option<String>,
@ -49,18 +47,18 @@ impl Song {
}
}
#[derive(Debug, PartialEq, Eq, Queryable, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Directory {
#[serde(skip_serializing, skip_deserializing)]
id: i32,
pub id: i64,
pub path: String,
#[serde(skip_serializing, skip_deserializing)]
pub parent: Option<String>,
pub artist: Option<String>,
pub year: Option<i32>,
pub year: Option<i64>,
pub album: Option<String>,
pub artwork: Option<String>,
pub date_added: i32,
pub date_added: i64,
}
impl Directory {

View file

@ -20,7 +20,7 @@ pub enum Error {
#[error(transparent)]
IndexClean(#[from] cleaner::Error),
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error(transparent)]
@ -28,46 +28,44 @@ pub enum Error {
}
impl Index {
pub fn update(&self) -> Result<(), Error> {
pub async fn update(&self) -> Result<(), Error> {
let start = time::Instant::now();
info!("Beginning library index update");
let album_art_pattern = self.settings_manager.get_index_album_art_pattern().ok();
let album_art_pattern = self
.settings_manager
.get_index_album_art_pattern()
.await
.ok();
let cleaner = Cleaner::new(self.db.clone(), self.vfs_manager.clone());
cleaner.clean()?;
cleaner.clean().await?;
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 (insert_sender, insert_receiver) = tokio::sync::mpsc::unbounded_channel();
let insertion = tokio::spawn({
let db = self.db.clone();
async {
let mut inserter = Inserter::new(db, insert_receiver);
inserter.insert().await;
}
});
let (collect_sender, collect_receiver) = crossbeam_channel::unbounded();
let collector_thread = std::thread::spawn(move || {
let collection = tokio::task::spawn_blocking(|| {
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 vfs = self.vfs_manager.get_vfs().await?;
let traversal = tokio::task::spawn_blocking(move || {
let mounts = vfs.mounts();
let traverser = Traverser::new(collect_sender);
traverser.traverse(mounts.iter().map(|p| p.source.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);
}
traversal.await.unwrap();
collection.await.unwrap();
insertion.await.unwrap();
info!(
"Library index update took {} seconds",

View file

@ -1,16 +1,16 @@
use diesel::prelude::*;
use rayon::prelude::*;
use sqlx::{QueryBuilder, Sqlite};
use std::path::Path;
use crate::app::vfs;
use crate::db::{self, directories, songs, DB};
use crate::db::{self, DB};
const INDEX_BUILDING_CLEAN_BUFFER_SIZE: usize = 500; // Deletions in each transaction
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error(transparent)]
@ -29,19 +29,23 @@ impl Cleaner {
Self { db, vfs_manager }
}
pub fn clean(&self) -> Result<(), Error> {
let vfs = self.vfs_manager.get_vfs()?;
pub async fn clean(&self) -> Result<(), Error> {
let vfs = self.vfs_manager.get_vfs().await?;
let all_directories: Vec<String> = {
let mut connection = self.db.connect()?;
directories::table
.select(directories::path)
.load(&mut connection)?
};
let (all_directories, all_songs) = {
let mut connection = self.db.connect().await?;
let all_songs: Vec<String> = {
let mut connection = self.db.connect()?;
songs::table.select(songs::path).load(&mut connection)?
let directories = sqlx::query_scalar!("SELECT path FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let songs = sqlx::query_scalar!("SELECT path FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
(directories, songs)
};
let list_missing_directories = || {
@ -69,14 +73,26 @@ impl Cleaner {
thread_pool.join(list_missing_directories, list_missing_songs);
{
let mut connection = self.db.connect()?;
let mut connection = self.db.connect().await?;
for chunk in missing_directories[..].chunks(INDEX_BUILDING_CLEAN_BUFFER_SIZE) {
diesel::delete(directories::table.filter(directories::path.eq_any(chunk)))
.execute(&mut connection)?;
QueryBuilder::<Sqlite>::new("DELETE FROM directories WHERE path IN ")
.push_tuples(chunk, |mut b, path| {
b.push_bind(path);
})
.build()
.execute(connection.as_mut())
.await?;
}
for chunk in missing_songs[..].chunks(INDEX_BUILDING_CLEAN_BUFFER_SIZE) {
diesel::delete(songs::table.filter(songs::path.eq_any(chunk)))
.execute(&mut connection)?;
QueryBuilder::<Sqlite>::new("DELETE FROM songs WHERE path IN ")
.push_tuples(chunk, |mut b, path| {
b.push_bind(path);
})
.build()
.execute(connection.as_mut())
.await?;
}
}

View file

@ -1,19 +1,18 @@
use crossbeam_channel::{Receiver, Sender};
use log::error;
use regex::Regex;
use super::*;
pub struct Collector {
receiver: Receiver<traverser::Directory>,
sender: Sender<inserter::Item>,
receiver: crossbeam_channel::Receiver<traverser::Directory>,
sender: tokio::sync::mpsc::UnboundedSender<inserter::Item>,
album_art_pattern: Option<Regex>,
}
impl Collector {
pub fn new(
receiver: Receiver<traverser::Directory>,
sender: Sender<inserter::Item>,
receiver: crossbeam_channel::Receiver<traverser::Directory>,
sender: tokio::sync::mpsc::UnboundedSender<inserter::Item>,
album_art_pattern: Option<Regex>,
) -> Self {
Self {

View file

@ -1,13 +1,11 @@
use crossbeam_channel::Receiver;
use diesel::prelude::*;
use log::error;
use sqlx::{QueryBuilder, Sqlite};
use tokio::sync::mpsc::UnboundedReceiver;
use crate::db::{directories, songs, DB};
use crate::db::DB;
const INDEX_BUILDING_INSERT_BUFFER_SIZE: usize = 1000; // Insertions in each transaction
#[derive(Debug, Insertable)]
#[diesel(table_name = songs)]
pub struct Song {
pub path: String,
pub parent: String,
@ -26,8 +24,6 @@ pub struct Song {
pub label: Option<String>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = directories)]
pub struct Directory {
pub path: String,
pub parent: Option<String>,
@ -44,14 +40,14 @@ pub enum Item {
}
pub struct Inserter {
receiver: Receiver<Item>,
receiver: UnboundedReceiver<Item>,
new_directories: Vec<Directory>,
new_songs: Vec<Song>,
db: DB,
}
impl Inserter {
pub fn new(db: DB, receiver: Receiver<Item>) -> Self {
pub fn new(db: DB, receiver: UnboundedReceiver<Item>) -> Self {
let new_directories = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE);
let new_songs = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE);
Self {
@ -62,63 +58,90 @@ impl Inserter {
}
}
pub fn insert(&mut self) {
while let Ok(item) = self.receiver.recv() {
self.insert_item(item);
pub async fn insert(&mut self) {
while let Some(item) = self.receiver.recv().await {
self.insert_item(item).await;
}
self.flush_directories().await;
self.flush_songs().await;
}
fn insert_item(&mut self, insert: Item) {
async fn insert_item(&mut self, insert: Item) {
match insert {
Item::Directory(d) => {
self.new_directories.push(d);
if self.new_directories.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE {
self.flush_directories();
self.flush_directories().await;
}
}
Item::Song(s) => {
self.new_songs.push(s);
if self.new_songs.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE {
self.flush_songs();
self.flush_songs().await;
}
}
};
}
fn flush_directories(&mut self) {
let res = self.db.connect().ok().and_then(|mut connection| {
diesel::insert_into(directories::table)
.values(&self.new_directories)
.execute(&mut *connection) // TODO https://github.com/diesel-rs/diesel/issues/1822
.ok()
});
if res.is_none() {
error!("Could not insert new directories in database");
}
self.new_directories.clear();
async fn flush_directories(&mut self) {
let Ok(mut connection) = self.db.connect().await else {
error!("Could not acquire connection to insert new directories in database");
return;
};
let result = QueryBuilder::<Sqlite>::new(
"INSERT INTO directories(path, parent, artist, year, album, artwork, date_added) ",
)
.push_values(&self.new_directories, |mut b, directory| {
b.push_bind(&directory.path)
.push_bind(&directory.parent)
.push_bind(&directory.artist)
.push_bind(directory.year)
.push_bind(&directory.album)
.push_bind(&directory.artwork)
.push_bind(directory.date_added);
})
.build()
.execute(connection.as_mut())
.await;
match result {
Ok(_) => self.new_directories.clear(),
Err(_) => error!("Could not insert new directories in database"),
};
}
fn flush_songs(&mut self) {
let res = self.db.connect().ok().and_then(|mut connection| {
diesel::insert_into(songs::table)
.values(&self.new_songs)
.execute(&mut *connection) // TODO https://github.com/diesel-rs/diesel/issues/1822
.ok()
});
if res.is_none() {
error!("Could not insert new songs in database");
}
self.new_songs.clear();
}
}
async fn flush_songs(&mut self) {
let Ok(mut connection) = self.db.connect().await else {
error!("Could not acquire connection to insert new songs in database");
return;
};
impl Drop for Inserter {
fn drop(&mut self) {
if !self.new_directories.is_empty() {
self.flush_directories();
}
if !self.new_songs.is_empty() {
self.flush_songs();
}
let result = QueryBuilder::<Sqlite>::new("INSERT INTO songs(path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork, duration, lyricist, composer, genre, label) ")
.push_values(&self.new_songs, |mut b, song| {
b.push_bind(&song.path)
.push_bind(&song.parent)
.push_bind(song.track_number)
.push_bind(song.disc_number)
.push_bind(&song.title)
.push_bind(&song.artist)
.push_bind(&song.album_artist)
.push_bind(song.year)
.push_bind(&song.album)
.push_bind(&song.artwork)
.push_bind(song.duration)
.push_bind(&song.lyricist)
.push_bind(&song.composer)
.push_bind(&song.genre)
.push_bind(&song.label);
})
.build()
.execute(connection.as_mut())
.await;
match result {
Ok(_) => self.new_songs.clear(),
Err(_) => error!("Could not insert new songs in database"),
};
}
}

View file

@ -44,7 +44,7 @@ impl Manager {
.map_err(|e| e.into())
}
pub fn link(&self, username: &str, lastfm_token: &str) -> Result<(), Error> {
pub async fn link(&self, username: &str, lastfm_token: &str) -> Result<(), Error> {
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let auth_response = scrobbler
.authenticate_with_token(lastfm_token)
@ -52,28 +52,30 @@ impl Manager {
self.user_manager
.lastfm_link(username, &auth_response.name, &auth_response.key)
.await
.map_err(|e| e.into())
}
pub fn unlink(&self, username: &str) -> Result<(), Error> {
pub async fn unlink(&self, username: &str) -> Result<(), Error> {
self.user_manager
.lastfm_unlink(username)
.await
.map_err(|e| e.into())
}
pub fn scrobble(&self, username: &str, track: &Path) -> Result<(), Error> {
pub async fn scrobble(&self, username: &str, track: &Path) -> Result<(), Error> {
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let scrobble = self.scrobble_from_path(track)?;
let auth_token = self.user_manager.get_lastfm_session_key(username)?;
let scrobble = self.scrobble_from_path(track).await?;
let auth_token = self.user_manager.get_lastfm_session_key(username).await?;
scrobbler.authenticate_with_session_key(&auth_token);
scrobbler.scrobble(&scrobble).map_err(Error::Scrobble)?;
Ok(())
}
pub fn now_playing(&self, username: &str, track: &Path) -> Result<(), Error> {
pub async fn now_playing(&self, username: &str, track: &Path) -> Result<(), Error> {
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let scrobble = self.scrobble_from_path(track)?;
let auth_token = self.user_manager.get_lastfm_session_key(username)?;
let scrobble = self.scrobble_from_path(track).await?;
let auth_token = self.user_manager.get_lastfm_session_key(username).await?;
scrobbler.authenticate_with_session_key(&auth_token);
scrobbler
.now_playing(&scrobble)
@ -81,8 +83,8 @@ impl Manager {
Ok(())
}
fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble, Error> {
let song = self.index.get_song(track)?;
async fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble, Error> {
let song = self.index.get_song(track).await?;
Ok(Scrobble::new(
song.artist.as_deref().unwrap_or(""),
song.title.as_deref().unwrap_or(""),

View file

@ -1,17 +1,14 @@
use core::clone::Clone;
use diesel::prelude::*;
use diesel::sql_types;
use diesel::BelongingToDsl;
use std::path::Path;
use sqlx::{Acquire, QueryBuilder, Sqlite};
use crate::app::index::Song;
use crate::app::vfs;
use crate::db::{self, playlist_songs, playlists, users, DB};
use crate::db::{self, DB};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error("User not found")]
@ -33,148 +30,138 @@ impl Manager {
Self { db, vfs_manager }
}
pub fn list_playlists(&self, owner: &str) -> Result<Vec<String>, Error> {
let mut connection = self.db.connect()?;
pub async fn list_playlists(&self, owner: &str) -> Result<Vec<String>, Error> {
let mut connection = self.db.connect().await?;
let user: User = {
use self::users::dsl::*;
users
.filter(name.eq(owner))
.select((id,))
.first(&mut connection)
.optional()?
.ok_or(Error::UserNotFound)?
};
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE name = $1", owner)
.fetch_optional(connection.as_mut())
.await?
.ok_or(Error::UserNotFound)?;
{
use self::playlists::dsl::*;
let found_playlists: Vec<String> = Playlist::belonging_to(&user)
.select(name)
.load(&mut connection)?;
Ok(found_playlists)
}
Ok(
sqlx::query_scalar!("SELECT name FROM playlists WHERE owner = $1", user_id)
.fetch_all(connection.as_mut())
.await?,
)
}
pub fn save_playlist(
pub async 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 vfs = self.vfs_manager.get_vfs().await?;
{
let mut connection = self.db.connect()?;
// Find owner
let user: User = {
use self::users::dsl::*;
users
.filter(name.eq(owner))
.select((id,))
.first(&mut connection)
.optional()?
.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(&mut connection)?;
playlist = {
use self::playlists::dsl::*;
playlists
.select((id, owner))
.filter(name.eq(playlist_name).and(owner.eq(user.id)))
.get_result(&mut connection)?
}
struct PlaylistSong {
path: String,
ordering: i64,
}
let mut new_songs: Vec<NewPlaylistSong> = Vec::with_capacity(content.len());
let mut new_songs: Vec<PlaylistSong> = Vec::with_capacity(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)
.virtual_to_real(path)
.ok()
.and_then(|p| p.to_str().map(|s| s.to_owned()))
{
new_songs.push(NewPlaylistSong {
playlist: playlist.id,
new_songs.push(PlaylistSong {
path: real_path,
ordering: i as i32,
ordering: i as i64,
});
}
}
{
let mut connection = self.db.connect()?;
connection.transaction::<_, diesel::result::Error, _>(|connection| {
// Delete old content (if any)
let old_songs = PlaylistSong::belonging_to(&playlist);
diesel::delete(old_songs).execute(connection)?;
// Create playlist
let mut connection = self.db.connect().await?;
// Insert content
diesel::insert_into(playlist_songs::table)
.values(&new_songs)
.execute(&mut *connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
Ok(())
})?;
// Find owner
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE name = $1", owner)
.fetch_optional(connection.as_mut())
.await?
.ok_or(Error::UserNotFound)?;
sqlx::query!(
"INSERT INTO playlists (owner, name) VALUES($1, $2)",
user_id,
playlist_name
)
.execute(connection.as_mut())
.await?;
let playlist_id = sqlx::query_scalar!(
"SELECT id FROM playlists WHERE owner = $1 AND name = $2",
user_id,
playlist_name
)
.fetch_one(connection.as_mut())
.await?;
connection.acquire().await?;
sqlx::query!(
"DELETE FROM playlist_songs WHERE playlist = $1",
playlist_id
)
.execute(connection.as_mut())
.await?;
if !new_songs.is_empty() {
QueryBuilder::<Sqlite>::new("INSERT INTO playlist_songs (playlist, path, ordering) ")
.push_values(new_songs, |mut b, song| {
b.push_bind(playlist_id)
.push_bind(song.path)
.push_bind(song.ordering);
})
.build()
.execute(connection.as_mut())
.await?;
}
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>;
pub async fn read_playlist(
&self,
playlist_name: &str,
owner: &str,
) -> Result<Vec<Song>, Error> {
let vfs = self.vfs_manager.get_vfs().await?;
{
let mut connection = self.db.connect()?;
let songs = {
let mut connection = self.db.connect().await?;
// Find owner
let user: User = {
use self::users::dsl::*;
users
.filter(name.eq(owner))
.select((id,))
.first(&mut connection)
.optional()?
.ok_or(Error::UserNotFound)?
};
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE name = $1", owner)
.fetch_optional(connection.as_mut())
.await?
.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(&mut connection)
.optional()?
.ok_or(Error::PlaylistNotFound)?
};
let playlist_id = sqlx::query_scalar!(
"SELECT id FROM playlists WHERE name = $1 and owner = $2",
playlist_name,
user_id
)
.fetch_optional(connection.as_mut())
.await?
.ok_or(Error::PlaylistNotFound)?;
// Select songs. Not using Diesel because we need to LEFT JOIN using a custom column
let query = diesel::sql_query(
// List songs
sqlx::query_as!(
Song,
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, s.lyricist, s.composer, s.genre, s.label
FROM playlist_songs ps
LEFT JOIN songs s ON ps.path = s.path
WHERE ps.playlist = ?
ORDER BY ps.ordering
"#,
);
let query = query.bind::<sql_types::Integer, _>(playlist.id);
songs = query.get_results(&mut connection)?;
}
SELECT s.*
FROM playlist_songs ps
INNER JOIN songs s ON ps.path = s.path
WHERE ps.playlist = $1
ORDER BY ps.ordering
"#,
playlist_id
)
.fetch_all(connection.as_mut())
.await?
};
// Map real path to virtual paths
let virtual_songs = songs
@ -185,64 +172,30 @@ impl Manager {
Ok(virtual_songs)
}
pub fn delete_playlist(&self, playlist_name: &str, owner: &str) -> Result<(), Error> {
let mut connection = self.db.connect()?;
pub async fn delete_playlist(&self, playlist_name: &str, owner: &str) -> Result<(), Error> {
let mut connection = self.db.connect().await?;
let user: User = {
use self::users::dsl::*;
users
.filter(name.eq(owner))
.select((id,))
.first(&mut connection)
.optional()?
.ok_or(Error::UserNotFound)?
};
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE name = $1", owner)
.fetch_optional(connection.as_mut())
.await?
.ok_or(Error::UserNotFound)?;
{
use self::playlists::dsl::*;
let q = Playlist::belonging_to(&user).filter(name.eq(playlist_name));
match diesel::delete(q).execute(&mut connection)? {
0 => Err(Error::PlaylistNotFound),
_ => Ok(()),
}
let num_deletions = sqlx::query_scalar!(
"DELETE FROM playlists WHERE owner = $1 AND name = $2",
user_id,
playlist_name
)
.execute(connection.as_mut())
.await?
.rows_affected();
match num_deletions {
0 => Err(Error::PlaylistNotFound),
_ => Ok(()),
}
}
}
#[derive(Identifiable, Queryable, Associations)]
#[diesel(belongs_to(User, foreign_key = owner))]
struct Playlist {
id: i32,
owner: i32,
}
#[derive(Identifiable, Queryable, Associations)]
#[diesel(belongs_to(Playlist, foreign_key = playlist))]
struct PlaylistSong {
id: i32,
playlist: i32,
}
#[derive(Insertable)]
#[diesel(table_name = playlists)]
struct NewPlaylist {
name: String,
owner: i32,
}
#[derive(Insertable)]
#[diesel(table_name = playlist_songs)]
struct NewPlaylistSong {
playlist: i32,
path: String,
ordering: i32,
}
#[derive(Identifiable, Queryable)]
struct User {
id: i32,
}
#[cfg(test)]
mod test {
use std::path::{Path, PathBuf};
@ -255,33 +208,41 @@ mod test {
const TEST_PLAYLIST_NAME: &str = "Chill & Grill";
const TEST_MOUNT_NAME: &str = "root";
#[test]
fn save_playlist_golden_path() {
#[tokio::test]
async fn save_playlist_golden_path() {
let ctx = test::ContextBuilder::new(test_name!())
.user(TEST_USER, TEST_PASSWORD, false)
.build();
.build()
.await;
ctx.playlist_manager
.save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &Vec::new())
.await
.unwrap();
let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap();
let found_playlists = ctx
.playlist_manager
.list_playlists(TEST_USER)
.await
.unwrap();
assert_eq!(found_playlists.len(), 1);
assert_eq!(found_playlists[0], TEST_PLAYLIST_NAME);
}
#[test]
fn save_playlist_is_idempotent() {
#[tokio::test]
async fn save_playlist_is_idempotent() {
let ctx = test::ContextBuilder::new(test_name!())
.user(TEST_USER, TEST_PASSWORD, false)
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
let playlist_content: Vec<String> = ctx
.index
.flatten(Path::new(TEST_MOUNT_NAME))
.await
.unwrap()
.into_iter()
.map(|s| s.path)
@ -290,51 +251,63 @@ mod test {
ctx.playlist_manager
.save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content)
.await
.unwrap();
ctx.playlist_manager
.save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content)
.await
.unwrap();
let songs = ctx
.playlist_manager
.read_playlist(TEST_PLAYLIST_NAME, TEST_USER)
.await
.unwrap();
assert_eq!(songs.len(), 13);
}
#[test]
fn delete_playlist_golden_path() {
#[tokio::test]
async fn delete_playlist_golden_path() {
let ctx = test::ContextBuilder::new(test_name!())
.user(TEST_USER, TEST_PASSWORD, false)
.build();
.build()
.await;
let playlist_content = Vec::new();
ctx.playlist_manager
.save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content)
.await
.unwrap();
ctx.playlist_manager
.delete_playlist(TEST_PLAYLIST_NAME, TEST_USER)
.await
.unwrap();
let found_playlists = ctx.playlist_manager.list_playlists(TEST_USER).unwrap();
let found_playlists = ctx
.playlist_manager
.list_playlists(TEST_USER)
.await
.unwrap();
assert_eq!(found_playlists.len(), 0);
}
#[test]
fn read_playlist_golden_path() {
#[tokio::test]
async fn read_playlist_golden_path() {
let ctx = test::ContextBuilder::new(test_name!())
.user(TEST_USER, TEST_PASSWORD, false)
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build();
.build()
.await;
ctx.index.update().unwrap();
ctx.index.update().await.unwrap();
let playlist_content: Vec<String> = ctx
.index
.flatten(Path::new(TEST_MOUNT_NAME))
.await
.unwrap()
.into_iter()
.map(|s| s.path)
@ -343,11 +316,13 @@ mod test {
ctx.playlist_manager
.save_playlist(TEST_PLAYLIST_NAME, TEST_USER, &playlist_content)
.await
.unwrap();
let songs = ctx
.playlist_manager
.read_playlist(TEST_PLAYLIST_NAME, TEST_USER)
.await
.unwrap();
assert_eq!(songs.len(), 13);

View file

@ -1,10 +1,8 @@
use diesel::prelude::*;
use regex::Regex;
use serde::Deserialize;
use std::convert::TryInto;
use std::time::Duration;
use crate::db::{self, misc_settings, DB};
use crate::db::{self, DB};
#[derive(thiserror::Error, Debug)]
pub enum Error {
@ -19,7 +17,7 @@ pub enum Error {
#[error("Index album art pattern is not a valid regex")]
IndexAlbumArtPatternInvalid,
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
}
#[derive(Clone, Default)]
@ -27,15 +25,15 @@ pub struct AuthSecret {
pub key: [u8; 32],
}
#[derive(Debug, Queryable)]
#[derive(Debug)]
pub struct Settings {
pub index_sleep_duration_seconds: i32,
pub index_sleep_duration_seconds: i64,
pub index_album_art_pattern: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct NewSettings {
pub reindex_every_n_seconds: Option<i32>,
pub reindex_every_n_seconds: Option<i64>,
pub album_art_pattern: Option<String>,
}
@ -49,64 +47,57 @@ impl Manager {
Self { db }
}
pub fn get_auth_secret(&self) -> Result<AuthSecret, Error> {
use self::misc_settings::dsl::*;
let mut connection = self.db.connect()?;
let secret: Vec<u8> = misc_settings
.select(auth_secret)
.get_result(&mut connection)
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::AuthenticationSecretNotFound,
e => e.into(),
})?;
secret
pub async fn get_auth_secret(&self) -> Result<AuthSecret, Error> {
sqlx::query_scalar!("SELECT auth_secret FROM config")
.fetch_one(self.db.connect().await?.as_mut())
.await?
.try_into()
.map_err(|_| Error::AuthenticationSecretInvalid)
.map(|key| AuthSecret { key })
}
pub fn get_index_sleep_duration(&self) -> Result<Duration, Error> {
let settings = self.read()?;
pub async fn get_index_sleep_duration(&self) -> Result<Duration, Error> {
let settings = self.read().await?;
Ok(Duration::from_secs(
settings.index_sleep_duration_seconds as u64,
))
}
pub fn get_index_album_art_pattern(&self) -> Result<Regex, Error> {
let settings = self.read()?;
pub async fn get_index_album_art_pattern(&self) -> Result<Regex, Error> {
let settings = self.read().await?;
let regex = Regex::new(&format!("(?i){}", &settings.index_album_art_pattern))
.map_err(|_| Error::IndexAlbumArtPatternInvalid)?;
Ok(regex)
}
pub fn read(&self) -> Result<Settings, Error> {
use self::misc_settings::dsl::*;
let mut connection = self.db.connect()?;
let settings: Settings = misc_settings
.select((index_sleep_duration_seconds, index_album_art_pattern))
.get_result(&mut connection)
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::MiscSettingsNotFound,
e => e.into(),
})?;
Ok(settings)
pub async fn read(&self) -> Result<Settings, Error> {
Ok(sqlx::query_as!(
Settings,
"SELECT index_sleep_duration_seconds,index_album_art_pattern FROM config"
)
.fetch_one(self.db.connect().await?.as_mut())
.await?)
}
pub fn amend(&self, new_settings: &NewSettings) -> Result<(), Error> {
let mut connection = self.db.connect()?;
pub async fn amend(&self, new_settings: &NewSettings) -> Result<(), Error> {
let mut connection = self.db.connect().await?;
if let Some(sleep_duration) = new_settings.reindex_every_n_seconds {
diesel::update(misc_settings::table)
.set(misc_settings::index_sleep_duration_seconds.eq(sleep_duration))
.execute(&mut connection)?;
sqlx::query!(
"UPDATE config SET index_sleep_duration_seconds = $1",
sleep_duration
)
.execute(connection.as_mut())
.await?;
}
if let Some(ref album_art_pattern) = new_settings.album_art_pattern {
diesel::update(misc_settings::table)
.set(misc_settings::index_album_art_pattern.eq(album_art_pattern))
.execute(&mut connection)?;
sqlx::query!(
"UPDATE config SET index_album_art_pattern = $1",
album_art_pattern
)
.execute(connection.as_mut())
.await?;
}
Ok(())

View file

@ -1,6 +1,6 @@
use std::path::PathBuf;
use crate::app::{config, ddns, index::Index, lastfm, playlist, settings, thumbnail, user, vfs};
use crate::app::{config, ddns, index::Index, playlist, settings, user, vfs};
use crate::db::DB;
use crate::test::*;
@ -9,13 +9,10 @@ pub struct Context {
pub index: Index,
pub config_manager: config::Manager,
pub ddns_manager: ddns::Manager,
pub lastfm_manager: lastfm::Manager,
pub playlist_manager: playlist::Manager,
pub settings_manager: settings::Manager,
pub thumbnail_manager: thumbnail::Manager,
pub user_manager: user::Manager,
pub vfs_manager: vfs::Manager,
pub test_directory: PathBuf,
}
pub struct ContextBuilder {
@ -53,14 +50,12 @@ impl ContextBuilder {
});
self
}
pub fn build(self) -> Context {
let cache_output_dir = self.test_directory.join("cache");
pub async fn build(self) -> Context {
let db_path = self.test_directory.join("db.sqlite");
let db = DB::new(&db_path).unwrap();
let db = DB::new(&db_path).await.unwrap();
let settings_manager = settings::Manager::new(db.clone());
let auth_secret = settings_manager.get_auth_secret().unwrap();
let auth_secret = settings_manager.get_auth_secret().await.unwrap();
let user_manager = user::Manager::new(db.clone(), auth_secret);
let vfs_manager = vfs::Manager::new(db.clone());
let ddns_manager = ddns::Manager::new(db.clone());
@ -72,23 +67,18 @@ impl ContextBuilder {
);
let index = Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
let playlist_manager = playlist::Manager::new(db.clone(), vfs_manager.clone());
let thumbnail_manager = thumbnail::Manager::new(cache_output_dir);
let lastfm_manager = lastfm::Manager::new(index.clone(), user_manager.clone());
config_manager.apply(&self.config).unwrap();
config_manager.apply(&self.config).await.unwrap();
Context {
db,
index,
config_manager,
ddns_manager,
lastfm_manager,
playlist_manager,
settings_manager,
thumbnail_manager,
user_manager,
vfs_manager,
test_directory: self.test_directory,
}
}
}

View file

@ -1,4 +1,3 @@
use diesel::prelude::*;
use pbkdf2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use pbkdf2::Pbkdf2;
use rand::rngs::OsRng;
@ -6,12 +5,12 @@ use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app::settings::AuthSecret;
use crate::db::{self, users, DB};
use crate::db::{self, DB};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error("Cannot use empty username")]
@ -36,12 +35,10 @@ pub enum Error {
BrancaTokenEncoding,
}
#[derive(Debug, Insertable, Queryable)]
#[diesel(table_name = users)]
#[derive(Debug)]
pub struct User {
pub name: String,
pub password_hash: String,
pub admin: i32,
pub admin: i64,
}
impl User {
@ -90,61 +87,62 @@ impl Manager {
Self { db, auth_secret }
}
pub fn create(&self, new_user: &NewUser) -> Result<(), Error> {
pub async fn create(&self, new_user: &NewUser) -> Result<(), Error> {
if new_user.name.is_empty() {
return Err(Error::EmptyUsername);
}
let password_hash = hash_password(&new_user.password)?;
let mut connection = self.db.connect()?;
let new_user = User {
name: new_user.name.to_owned(),
sqlx::query!(
"INSERT INTO users (name, password_hash, admin) VALUES($1, $2, $3)",
new_user.name,
password_hash,
admin: new_user.admin as i32,
};
new_user.admin
)
.execute(self.db.connect().await?.as_mut())
.await?;
diesel::insert_into(users::table)
.values(&new_user)
.execute(&mut connection)?;
Ok(())
}
pub fn delete(&self, username: &str) -> Result<(), Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
diesel::delete(users.filter(name.eq(username))).execute(&mut connection)?;
pub async fn delete(&self, username: &str) -> Result<(), Error> {
sqlx::query!("DELETE FROM users WHERE name = $1", username)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
pub async fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
let hash = hash_password(password)?;
let mut connection = self.db.connect()?;
use crate::db::users::dsl::*;
diesel::update(users.filter(name.eq(username)))
.set(password_hash.eq(hash))
.execute(&mut connection)?;
sqlx::query!(
"UPDATE users SET password_hash = $1 WHERE name = $2",
hash,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn set_is_admin(&self, username: &str, is_admin: bool) -> Result<(), Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
diesel::update(users.filter(name.eq(username)))
.set(admin.eq(is_admin as i32))
.execute(&mut connection)?;
pub async fn set_is_admin(&self, username: &str, is_admin: bool) -> Result<(), Error> {
sqlx::query!(
"UPDATE users SET admin = $1 WHERE name = $2",
is_admin,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn login(&self, username: &str, password: &str) -> Result<AuthToken, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
match users
.select(password_hash)
.filter(name.eq(username))
.get_result(&mut connection)
pub async fn login(&self, username: &str, password: &str) -> Result<AuthToken, Error> {
match sqlx::query_scalar!("SELECT password_hash FROM users WHERE name = $1", username)
.fetch_optional(self.db.connect().await?.as_mut())
.await?
{
Err(diesel::result::Error::NotFound) => Err(Error::IncorrectUsername),
Ok(hash) => {
None => Err(Error::IncorrectUsername),
Some(hash) => {
let hash: String = hash;
if verify_password(&hash, password) {
let authorization = Authorization {
@ -156,17 +154,16 @@ impl Manager {
Err(Error::IncorrectPassword)
}
}
Err(e) => Err(e.into()),
}
}
pub fn authenticate(
pub async fn authenticate(
&self,
auth_token: &AuthToken,
scope: AuthorizationScope,
) -> Result<Authorization, Error> {
let authorization = self.decode_auth_token(auth_token, scope)?;
if self.exists(&authorization.username)? {
if self.exists(&authorization.username).await? {
Ok(authorization)
} else {
Err(Error::IncorrectUsername)
@ -208,86 +205,76 @@ impl Manager {
.map(AuthToken)
}
pub fn count(&self) -> Result<i64, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
let count = users.count().get_result(&mut connection)?;
pub async fn count(&self) -> Result<i32, Error> {
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(self.db.connect().await?.as_mut())
.await?;
Ok(count)
}
pub fn list(&self) -> Result<Vec<User>, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
let listed_users = users
.select((name, password_hash, admin))
.get_results(&mut connection)?;
pub async fn list(&self) -> Result<Vec<User>, Error> {
let listed_users = sqlx::query_as!(User, "SELECT name, admin FROM users")
.fetch_all(self.db.connect().await?.as_mut())
.await?;
Ok(listed_users)
}
pub fn exists(&self, username: &str) -> Result<bool, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
let results: Vec<String> = users
.select(name)
.filter(name.eq(username))
.get_results(&mut connection)?;
Ok(!results.is_empty())
pub async fn exists(&self, username: &str) -> Result<bool, Error> {
Ok(
0 < sqlx::query_scalar!("SELECT COUNT(*) FROM users WHERE name = $1", username)
.fetch_one(self.db.connect().await?.as_mut())
.await?,
)
}
pub fn is_admin(&self, username: &str) -> Result<bool, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
let is_admin: i32 = users
.filter(name.eq(username))
.select(admin)
.get_result(&mut connection)?;
Ok(is_admin != 0)
pub async fn is_admin(&self, username: &str) -> Result<bool, Error> {
Ok(
0 < sqlx::query_scalar!("SELECT admin FROM users WHERE name = $1", username)
.fetch_one(self.db.connect().await?.as_mut())
.await?,
)
}
pub fn read_preferences(&self, username: &str) -> Result<Preferences, Error> {
use crate::db::users::dsl::*;
let mut 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(&mut connection)?;
Ok(Preferences {
web_theme_base: theme_base,
web_theme_accent: theme_accent,
lastfm_username: read_lastfm_username,
})
pub async fn read_preferences(&self, username: &str) -> Result<Preferences, Error> {
Ok(sqlx::query_as!(
Preferences,
"SELECT web_theme_base, web_theme_accent, lastfm_username FROM users WHERE name = $1",
username
)
.fetch_one(self.db.connect().await?.as_mut())
.await?)
}
pub fn write_preferences(
pub async fn write_preferences(
&self,
username: &str,
preferences: &Preferences,
) -> Result<(), Error> {
use crate::db::users::dsl::*;
let mut 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(&mut connection)?;
sqlx::query!(
"UPDATE users SET web_theme_base = $1, web_theme_accent = $2 WHERE name = $3",
preferences.web_theme_base,
preferences.web_theme_accent,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn lastfm_link(
pub async fn lastfm_link(
&self,
username: &str,
lastfm_login: &str,
session_key: &str,
) -> Result<(), Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
diesel::update(users.filter(name.eq(username)))
.set((
lastfm_username.eq(lastfm_login),
lastfm_session_key.eq(session_key),
))
.execute(&mut connection)?;
sqlx::query!(
"UPDATE users SET lastfm_username = $1, lastfm_session_key = $2 WHERE name = $3",
lastfm_login,
session_key,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
@ -298,27 +285,29 @@ impl Manager {
})
}
pub fn get_lastfm_session_key(&self, username: &str) -> Result<String, Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
let token: Option<String> = users
.filter(name.eq(username))
.select(lastfm_session_key)
.get_result(&mut connection)?;
pub async fn get_lastfm_session_key(&self, username: &str) -> Result<String, Error> {
let token: Option<String> = sqlx::query_scalar!(
"SELECT lastfm_session_key FROM users WHERE name = $1",
username
)
.fetch_one(self.db.connect().await?.as_mut())
.await?;
token.ok_or(Error::MissingLastFMSessionKey)
}
pub fn is_lastfm_linked(&self, username: &str) -> bool {
self.get_lastfm_session_key(username).is_ok()
pub async fn is_lastfm_linked(&self, username: &str) -> bool {
self.get_lastfm_session_key(username).await.is_ok()
}
pub fn lastfm_unlink(&self, username: &str) -> Result<(), Error> {
use crate::db::users::dsl::*;
let mut connection = self.db.connect()?;
pub async fn lastfm_unlink(&self, username: &str) -> Result<(), Error> {
let null: Option<String> = None;
diesel::update(users.filter(name.eq(username)))
.set((lastfm_session_key.eq(&null), lastfm_username.eq(&null)))
.execute(&mut connection)?;
sqlx::query!(
"UPDATE users SET lastfm_session_key = $1, lastfm_username = $1 WHERE name = $2",
null,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
}
@ -352,9 +341,9 @@ mod test {
const TEST_USERNAME: &str = "Walter";
const TEST_PASSWORD: &str = "super_secret!";
#[test]
fn create_delete_user_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn create_delete_user_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
@ -362,56 +351,56 @@ mod test {
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
assert_eq!(ctx.user_manager.list().unwrap().len(), 1);
ctx.user_manager.create(&new_user).await.unwrap();
assert_eq!(ctx.user_manager.list().await.unwrap().len(), 1);
ctx.user_manager.delete(&new_user.name).unwrap();
assert_eq!(ctx.user_manager.list().unwrap().len(), 0);
ctx.user_manager.delete(&new_user.name).await.unwrap();
assert_eq!(ctx.user_manager.list().await.unwrap().len(), 0);
}
#[test]
fn cannot_create_user_with_blank_username() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn cannot_create_user_with_blank_username() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: "".to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
assert!(matches!(
ctx.user_manager.create(&new_user).unwrap_err(),
ctx.user_manager.create(&new_user).await.unwrap_err(),
Error::EmptyUsername
));
}
#[test]
fn cannot_create_user_with_blank_password() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn cannot_create_user_with_blank_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: "".to_owned(),
admin: false,
};
assert!(matches!(
ctx.user_manager.create(&new_user).unwrap_err(),
ctx.user_manager.create(&new_user).await.unwrap_err(),
Error::EmptyPassword
));
}
#[test]
fn cannot_create_duplicate_user() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn cannot_create_duplicate_user() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).unwrap_err();
ctx.user_manager.create(&new_user).await.unwrap();
ctx.user_manager.create(&new_user).await.unwrap_err();
}
#[test]
fn can_read_write_preferences() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn can_read_write_preferences() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_preferences = Preferences {
web_theme_base: Some("very-dark-theme".to_owned()),
@ -424,19 +413,20 @@ mod test {
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).await.unwrap();
ctx.user_manager
.write_preferences(TEST_USERNAME, &new_preferences)
.await
.unwrap();
let read_preferences = ctx.user_manager.read_preferences("Walter").unwrap();
let read_preferences = ctx.user_manager.read_preferences("Walter").await.unwrap();
assert_eq!(new_preferences, read_preferences);
}
#[test]
fn login_rejects_bad_password() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn login_rejects_bad_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
@ -444,30 +434,35 @@ mod test {
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).await.unwrap();
assert!(matches!(
ctx.user_manager
.login(TEST_USERNAME, "not the password")
.await
.unwrap_err(),
Error::IncorrectPassword
));
}
#[test]
fn login_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn login_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
assert!(ctx.user_manager.login(TEST_USERNAME, TEST_PASSWORD).is_ok())
ctx.user_manager.create(&new_user).await.unwrap();
assert!(ctx
.user_manager
.login(TEST_USERNAME, TEST_PASSWORD)
.await
.is_ok())
}
#[test]
fn authenticate_rejects_bad_token() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn authenticate_rejects_bad_token() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
@ -475,17 +470,18 @@ mod test {
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).await.unwrap();
let fake_token = AuthToken("fake token".to_owned());
assert!(ctx
.user_manager
.authenticate(&fake_token, AuthorizationScope::PolarisAuth)
.await
.is_err())
}
#[test]
fn authenticate_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn authenticate_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
@ -493,14 +489,16 @@ mod test {
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).await.unwrap();
let token = ctx
.user_manager
.login(TEST_USERNAME, TEST_PASSWORD)
.await
.unwrap();
let authorization = ctx
.user_manager
.authenticate(&token, AuthorizationScope::PolarisAuth)
.await
.unwrap();
assert_eq!(
authorization,
@ -511,9 +509,9 @@ mod test {
)
}
#[test]
fn authenticate_validates_scope() {
let ctx = test::ContextBuilder::new(test_name!()).build();
#[tokio::test]
async fn authenticate_validates_scope() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
@ -521,14 +519,15 @@ mod test {
admin: false,
};
ctx.user_manager.create(&new_user).unwrap();
ctx.user_manager.create(&new_user).await.unwrap();
let token = ctx
.user_manager
.generate_lastfm_link_token(TEST_USERNAME)
.unwrap();
let authorization = ctx
.user_manager
.authenticate(&token, AuthorizationScope::PolarisAuth);
.authenticate(&token, AuthorizationScope::PolarisAuth)
.await;
assert!(matches!(
authorization.unwrap_err(),
Error::IncorrectAuthorizationScope

View file

@ -1,10 +1,10 @@
use core::ops::Deref;
use diesel::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::{Acquire, QueryBuilder, Sqlite};
use std::path::{self, Path, PathBuf};
use crate::db::{self, mount_points, DB};
use crate::db::{self, DB};
#[derive(thiserror::Error, Debug)]
pub enum Error {
@ -15,11 +15,10 @@ pub enum Error {
#[error(transparent)]
DatabaseConnection(#[from] db::Error),
#[error(transparent)]
Database(#[from] diesel::result::Error),
Database(#[from] sqlx::Error),
}
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Eq, Queryable, Serialize)]
#[diesel(table_name = mount_points)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct MountDir {
pub source: String,
pub name: String,
@ -98,31 +97,39 @@ impl Manager {
Self { db }
}
pub fn get_vfs(&self) -> Result<VFS, Error> {
let mount_dirs = self.mount_dirs()?;
pub async fn get_vfs(&self) -> Result<VFS, Error> {
let mount_dirs = self.mount_dirs().await?;
let mounts = mount_dirs.into_iter().map(|p| p.into()).collect();
Ok(VFS::new(mounts))
}
pub fn mount_dirs(&self) -> Result<Vec<MountDir>, Error> {
use self::mount_points::dsl::*;
let mut connection = self.db.connect()?;
let mount_dirs: Vec<MountDir> = mount_points
.select((source, name))
.get_results(&mut connection)?;
Ok(mount_dirs)
pub async fn mount_dirs(&self) -> Result<Vec<MountDir>, Error> {
Ok(
sqlx::query_as!(MountDir, "SELECT source, name FROM mount_points")
.fetch_all(self.db.connect().await?.as_mut())
.await?,
)
}
pub fn set_mount_dirs(&self, mount_dirs: &[MountDir]) -> Result<(), Error> {
let mut connection = self.db.connect()?;
connection.transaction::<_, diesel::result::Error, _>(|connection| {
use self::mount_points::dsl::*;
diesel::delete(mount_points).execute(&mut *connection)?;
diesel::insert_into(mount_points)
.values(mount_dirs)
.execute(&mut *connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
Ok(())
})?;
pub async fn set_mount_dirs(&self, mount_dirs: &[MountDir]) -> Result<(), Error> {
let mut connection = self.db.connect().await?;
connection.begin().await?;
sqlx::query!("DELETE FROM mount_points")
.execute(connection.as_mut())
.await?;
if !mount_dirs.is_empty() {
QueryBuilder::<Sqlite>::new("INSERT INTO mount_points(source, name) ")
.push_values(mount_dirs, |mut b, dir| {
b.push_bind(&dir.source).push_bind(&dir.name);
})
.build()
.execute(connection.as_mut())
.await?;
}
Ok(())
}
}

View file

@ -1,15 +1,13 @@
use diesel::r2d2::{self, ConnectionManager, PooledConnection};
use diesel::sqlite::SqliteConnection;
use diesel::RunQueryDsl;
use diesel_migrations::EmbeddedMigrations;
use diesel_migrations::MigrationHarness;
use std::path::{Path, PathBuf};
mod schema;
use sqlx::{
migrate::Migrator,
pool::PoolConnection,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteSynchronous},
Sqlite,
};
pub use self::schema::*;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
static MIGRATOR: Migrator = sqlx::migrate!("src/db");
#[derive(thiserror::Error, Debug)]
pub enum Error {
@ -25,74 +23,46 @@ pub enum Error {
#[derive(Clone)]
pub struct DB {
pool: r2d2::Pool<ConnectionManager<SqliteConnection>>,
}
#[derive(Debug)]
struct ConnectionCustomizer {}
impl diesel::r2d2::CustomizeConnection<SqliteConnection, diesel::r2d2::Error>
for ConnectionCustomizer
{
fn on_acquire(&self, connection: &mut SqliteConnection) -> Result<(), diesel::r2d2::Error> {
let query = diesel::sql_query(
r#"
PRAGMA busy_timeout = 60000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
"#,
);
query
.execute(connection)
.map_err(diesel::r2d2::Error::QueryError)?;
Ok(())
}
pool: SqlitePool,
}
impl DB {
pub fn new(path: &Path) -> Result<DB, Error> {
pub async fn new(path: &Path) -> Result<DB, Error> {
let directory = path.parent().unwrap();
std::fs::create_dir_all(directory).map_err(|e| Error::Io(directory.to_owned(), e))?;
let manager = ConnectionManager::<SqliteConnection>::new(path.to_string_lossy());
let pool = diesel::r2d2::Pool::builder()
.connection_customizer(Box::new(ConnectionCustomizer {}))
.build(manager)
.or(Err(Error::ConnectionPoolBuild))?;
let pool = SqlitePool::connect_lazy_with(
SqliteConnectOptions::new()
.create_if_missing(true)
.filename(path)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal),
);
let db = DB { pool };
db.migrate_up()?;
db.migrate_up().await?;
Ok(db)
}
pub fn connect(&self) -> Result<PooledConnection<ConnectionManager<SqliteConnection>>, Error> {
self.pool.get().or(Err(Error::ConnectionPool))
pub async fn connect(&self) -> Result<PoolConnection<Sqlite>, Error> {
self.pool.acquire().await.map_err(|_| Error::ConnectionPool)
}
#[cfg(test)]
fn migrate_down(&self) -> Result<(), Error> {
let mut connection = self.connect()?;
connection
.revert_all_migrations(MIGRATIONS)
.and(Ok(()))
.or(Err(Error::Migration))
}
fn migrate_up(&self) -> Result<(), Error> {
let mut connection = self.connect()?;
connection
.run_pending_migrations(MIGRATIONS)
async fn migrate_up(&self) -> Result<(), Error> {
MIGRATOR
.run(&self.pool)
.await
.and(Ok(()))
.or(Err(Error::Migration))
}
}
#[test]
fn run_migrations() {
#[tokio::test]
async fn run_migrations() {
use crate::test::*;
use crate::test_name;
let output_dir = prepare_test_directory(test_name!());
let db_path = output_dir.join("db.sqlite");
let db = DB::new(&db_path).unwrap();
db.migrate_down().unwrap();
db.migrate_up().unwrap();
let db = DB::new(&db_path).await.unwrap();
db.migrate_up().await.unwrap();
}

View file

@ -0,0 +1,95 @@
CREATE TABLE config (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
auth_secret BLOB NOT NULL DEFAULT (randomblob(32)),
index_sleep_duration_seconds INTEGER NOT NULL,
index_album_art_pattern TEXT NOT NULL,
ddns_host TEXT NOT NULL,
ddns_username TEXT NOT NULL,
ddns_password TEXT NOT NULL
);
INSERT INTO config (
id,
auth_secret,
index_sleep_duration_seconds,
index_album_art_pattern,
ddns_host,
ddns_username,
ddns_password
) VALUES (
0,
randomblob(32),
1800,
"Folder.(jpeg|jpg|png)",
"",
"",
""
);
CREATE TABLE mount_points (
id INTEGER PRIMARY KEY NOT NULL,
source TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE(name)
);
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
admin INTEGER NOT NULL,
lastfm_username TEXT,
lastfm_session_key TEXT,
web_theme_base TEXT,
web_theme_accent TEXT,
UNIQUE(name)
);
CREATE TABLE directories (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT,
artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
date_added INTEGER DEFAULT 0 NOT NULL,
UNIQUE(path) ON CONFLICT REPLACE
);
CREATE TABLE songs (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
parent TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
title TEXT,
artist TEXT,
album_artist TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
duration INTEGER,
lyricist TEXT,
composer TEXT,
genre TEXT,
label TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);
CREATE TABLE playlists (
id INTEGER PRIMARY KEY NOT NULL,
owner INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY(owner) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(owner, name) ON CONFLICT REPLACE
);
CREATE TABLE playlist_songs (
id INTEGER PRIMARY KEY NOT NULL,
playlist INTEGER NOT NULL,
path TEXT NOT NULL,
ordering INTEGER NOT NULL,
FOREIGN KEY(playlist) REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE(playlist, ordering) ON CONFLICT REPLACE
);

View file

@ -1,103 +0,0 @@
table! {
ddns_config (id) {
id -> Integer,
host -> Text,
username -> Text,
password -> Text,
}
}
table! {
directories (id) {
id -> Integer,
path -> Text,
parent -> Nullable<Text>,
artist -> Nullable<Text>,
year -> Nullable<Integer>,
album -> Nullable<Text>,
artwork -> Nullable<Text>,
date_added -> Integer,
}
}
table! {
misc_settings (id) {
id -> Integer,
auth_secret -> Binary,
index_sleep_duration_seconds -> Integer,
index_album_art_pattern -> Text,
}
}
table! {
mount_points (id) {
id -> Integer,
source -> Text,
name -> Text,
}
}
table! {
playlist_songs (id) {
id -> Integer,
playlist -> Integer,
path -> Text,
ordering -> Integer,
}
}
table! {
playlists (id) {
id -> Integer,
owner -> Integer,
name -> Text,
}
}
table! {
songs (id) {
id -> Integer,
path -> Text,
parent -> Text,
track_number -> Nullable<Integer>,
disc_number -> Nullable<Integer>,
title -> Nullable<Text>,
artist -> Nullable<Text>,
album_artist -> Nullable<Text>,
year -> Nullable<Integer>,
album -> Nullable<Text>,
artwork -> Nullable<Text>,
duration -> Nullable<Integer>,
lyricist -> Nullable<Text>,
composer -> Nullable<Text>,
genre -> Nullable<Text>,
label -> Nullable<Text>,
}
}
table! {
users (id) {
id -> Integer,
name -> Text,
password_hash -> Text,
admin -> Integer,
lastfm_username -> Nullable<Text>,
lastfm_session_key -> Nullable<Text>,
web_theme_base -> Nullable<Text>,
web_theme_accent -> Nullable<Text>,
}
}
joinable!(playlist_songs -> playlists (playlist));
joinable!(playlists -> users (owner));
allow_tables_to_appear_in_same_query!(
ddns_config,
directories,
misc_settings,
mount_points,
playlist_songs,
playlists,
songs,
users,
);

View file

@ -1,12 +1,8 @@
#![cfg_attr(all(windows, feature = "ui"), windows_subsystem = "windows")]
#![recursion_limit = "256"]
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
use log::info;
use log::{error, info};
use options::CLIOptions;
use simplelog::{
ColorChoice, CombinedLogger, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger,
};
@ -27,6 +23,8 @@ mod utils;
pub enum Error {
#[error(transparent)]
App(#[from] app::Error),
#[error("Could not start web services")]
ServiceStartup(std::io::Error),
#[error("Could not parse command line arguments:\n\n{0}")]
CliArgsParsing(getopts::Fail),
#[cfg(unix)]
@ -139,16 +137,21 @@ fn main() -> Result<(), Error> {
info!("Swagger files location is {:#?}", paths.swagger_dir_path);
info!("Web client files location is {:#?}", paths.web_dir_path);
async_main(cli_options, paths)
}
#[tokio::main]
async fn async_main(cli_options: CLIOptions, paths: paths::Paths) -> Result<(), Error> {
// Create and run app
let app = app::App::new(cli_options.port.unwrap_or(5050), paths)?;
let app = app::App::new(cli_options.port.unwrap_or(5050), paths).await?;
app.index.begin_periodic_updates();
app.ddns_manager.begin_periodic_updates();
// Start server
info!("Starting up server");
std::thread::spawn(move || {
let _ = service::run(app);
});
if let Err(e) = service::launch(app) {
return Err(Error::ServiceStartup(e));
}
// Send readiness notification
#[cfg(unix)]

View file

@ -80,23 +80,23 @@ impl Paths {
pub fn new(cli_options: &CLIOptions) -> Self {
let mut paths = Self::from_build();
if let Some(path) = &cli_options.cache_dir_path {
paths.cache_dir_path = path.clone();
path.clone_into(&mut paths.cache_dir_path);
}
if let Some(path) = &cli_options.config_file_path {
paths.config_file_path = Some(path.clone());
}
if let Some(path) = &cli_options.database_file_path {
paths.db_file_path = path.clone();
path.clone_into(&mut paths.db_file_path);
}
#[cfg(unix)]
if let Some(path) = &cli_options.pid_file_path {
paths.pid_file_path = path.clone();
path.clone_into(&mut paths.pid_file_path);
}
if let Some(path) = &cli_options.swagger_dir_path {
paths.swagger_dir_path = path.clone();
path.clone_into(&mut paths.swagger_dir_path);
}
if let Some(path) = &cli_options.web_dir_path {
paths.web_dir_path = path.clone();
path.clone_into(&mut paths.web_dir_path);
}
let log_to_file = cli_options.log_file_path.is_some() || !cli_options.foreground;

View file

@ -1,7 +1,6 @@
use actix_web::{
dev::Service,
middleware::{Compress, Logger, NormalizePath},
rt::System,
web::{self, ServiceConfig},
App as ActixApp, HttpServer,
};
@ -43,9 +42,9 @@ pub fn make_config(app: App) -> impl FnOnce(&mut ServiceConfig) + Clone {
}
}
pub fn run(app: App) -> Result<(), std::io::Error> {
pub fn launch(app: App) -> Result<(), std::io::Error> {
let address = ("0.0.0.0", app.port);
System::new().block_on(
tokio::spawn(
HttpServer::new(move || {
ActixApp::new()
.wrap(Logger::default())
@ -72,5 +71,6 @@ pub fn run(app: App) -> Result<(), std::io::Error> {
e
})?
.run(),
)
);
Ok(())
}

View file

@ -16,7 +16,7 @@ use base64::prelude::*;
use futures_util::future::err;
use percent_encoding::percent_decode_str;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::pin::Pin;
use std::str;
@ -141,25 +141,27 @@ impl FromRequest for Auth {
// Auth via bearer token in query parameter
if let Ok(query) = query_params_future.await {
let auth_token = user::AuthToken(query.auth_token.clone());
let authorization = block(move || {
user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
})
.await?;
return Ok(Auth {
username: authorization.username,
});
if let Ok(auth) = user_manager
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
.await
{
return Ok(Auth {
username: auth.username,
});
}
}
// Auth via bearer token in authorization header
if let Ok(bearer_auth) = bearer_auth_future.await {
let auth_token = user::AuthToken(bearer_auth.token().to_owned());
let authorization = block(move || {
user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
})
.await?;
return Ok(Auth {
username: authorization.username,
});
if let Ok(auth) = user_manager
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
.await
{
return Ok(Auth {
username: auth.username,
});
}
}
Err(ErrorUnauthorized(APIError::AuthenticationRequired))
@ -185,21 +187,18 @@ impl FromRequest for AdminRights {
let auth_future = Auth::from_request(request, payload);
Box::pin(async move {
let user_manager_count = user_manager.clone();
let user_count = block(move || user_manager_count.count()).await;
let user_count = user_manager.count().await;
match user_count {
Err(e) => return Err(e.into()),
Err(_) => return Err(ErrorInternalServerError(APIError::Internal)),
Ok(0) => return Ok(AdminRights { auth: None }),
_ => (),
};
let auth = auth_future.await?;
let username = auth.username.clone();
let is_admin = block(move || user_manager.is_admin(&username)).await?;
if is_admin {
Ok(AdminRights { auth: Some(auth) })
} else {
Err(ErrorForbidden(APIError::AdminPermissionRequired))
match user_manager.is_admin(&auth.username).await {
Ok(true) => Ok(AdminRights { auth: Some(auth) }),
Ok(false) => Err(ErrorForbidden(APIError::AdminPermissionRequired)),
Err(_) => Err(ErrorInternalServerError(APIError::Internal)),
}
})
}
@ -228,18 +227,6 @@ impl Responder for MediaFile {
}
}
async fn block<F, I, E>(f: F) -> Result<I, APIError>
where
F: FnOnce() -> Result<I, E> + Send + 'static,
I: Send + 'static,
E: Send + std::fmt::Debug + 'static + Into<APIError>,
{
actix_web::web::block(f)
.await
.map_err(|_| APIError::Internal)
.and_then(|r| r.map_err(|e| e.into()))
}
#[get("/version")]
async fn version() -> Json<dto::Version> {
let current_version = dto::Version {
@ -253,14 +240,13 @@ async fn version() -> Json<dto::Version> {
async fn initial_setup(
user_manager: Data<user::Manager>,
) -> Result<Json<dto::InitialSetup>, APIError> {
let initial_setup = block(move || -> Result<dto::InitialSetup, APIError> {
let users = user_manager.list()?;
let initial_setup = {
let users = user_manager.list().await?;
let has_any_admin = users.iter().any(|u| u.is_admin());
Ok(dto::InitialSetup {
dto::InitialSetup {
has_any_users: has_any_admin,
})
})
.await?;
}
};
Ok(Json(initial_setup))
}
@ -270,7 +256,7 @@ async fn apply_config(
config_manager: Data<config::Manager>,
config: Json<dto::Config>,
) -> Result<HttpResponse, APIError> {
block(move || config_manager.apply(&config.to_owned().into())).await?;
config_manager.apply(&config.to_owned().into()).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -279,7 +265,7 @@ async fn get_settings(
settings_manager: Data<settings::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<dto::Settings>, APIError> {
let settings = block(move || settings_manager.read()).await?;
let settings = settings_manager.read().await?;
Ok(Json(settings.into()))
}
@ -289,7 +275,9 @@ async fn put_settings(
settings_manager: Data<settings::Manager>,
new_settings: Json<dto::NewSettings>,
) -> Result<HttpResponse, APIError> {
block(move || settings_manager.amend(&new_settings.to_owned().into())).await?;
settings_manager
.amend(&new_settings.to_owned().into())
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -298,7 +286,7 @@ async fn list_mount_dirs(
vfs_manager: Data<vfs::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<Vec<dto::MountDir>>, APIError> {
let mount_dirs = block(move || vfs_manager.mount_dirs()).await?;
let mount_dirs = vfs_manager.mount_dirs().await?;
let mount_dirs = mount_dirs.into_iter().map(|m| m.into()).collect();
Ok(Json(mount_dirs))
}
@ -310,7 +298,7 @@ async fn put_mount_dirs(
new_mount_dirs: Json<Vec<dto::MountDir>>,
) -> Result<HttpResponse, APIError> {
let new_mount_dirs: Vec<MountDir> = new_mount_dirs.iter().cloned().map(|m| m.into()).collect();
block(move || vfs_manager.set_mount_dirs(&new_mount_dirs)).await?;
vfs_manager.set_mount_dirs(&new_mount_dirs).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -319,7 +307,7 @@ async fn get_ddns_config(
ddns_manager: Data<ddns::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<dto::DDNSConfig>, APIError> {
let ddns_config = block(move || ddns_manager.config()).await?;
let ddns_config = ddns_manager.config().await?;
Ok(Json(ddns_config.into()))
}
@ -329,7 +317,9 @@ async fn put_ddns_config(
ddns_manager: Data<ddns::Manager>,
new_ddns_config: Json<dto::DDNSConfig>,
) -> Result<HttpResponse, APIError> {
block(move || ddns_manager.set_config(&new_ddns_config.to_owned().into())).await?;
ddns_manager
.set_config(&new_ddns_config.to_owned().into())
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -338,7 +328,7 @@ async fn list_users(
user_manager: Data<user::Manager>,
_admin_rights: AdminRights,
) -> Result<Json<Vec<dto::User>>, APIError> {
let users = block(move || user_manager.list()).await?;
let users = user_manager.list().await?;
let users = users.into_iter().map(|u| u.into()).collect();
Ok(Json(users))
}
@ -350,7 +340,7 @@ async fn create_user(
new_user: Json<dto::NewUser>,
) -> Result<HttpResponse, APIError> {
let new_user = new_user.to_owned().into();
block(move || user_manager.create(&new_user)).await?;
user_manager.create(&new_user).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -367,16 +357,14 @@ async fn update_user(
}
}
block(move || -> Result<(), APIError> {
if let Some(password) = &user_update.new_password {
user_manager.set_password(&name, password)?;
}
if let Some(is_admin) = &user_update.new_is_admin {
user_manager.set_is_admin(&name, *is_admin)?;
}
Ok(())
})
.await?;
if let Some(password) = &user_update.new_password {
user_manager.set_password(&name, password).await?;
}
if let Some(is_admin) = &user_update.new_is_admin {
user_manager.set_is_admin(&name, *is_admin).await?;
}
Ok(HttpResponse::new(StatusCode::OK))
}
@ -391,7 +379,7 @@ async fn delete_user(
return Err(APIError::DeletingOwnAccount);
}
}
block(move || user_manager.delete(&name)).await?;
user_manager.delete(&name).await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -400,7 +388,7 @@ async fn get_preferences(
user_manager: Data<user::Manager>,
auth: Auth,
) -> Result<Json<user::Preferences>, APIError> {
let preferences = block(move || user_manager.read_preferences(&auth.username)).await?;
let preferences = user_manager.read_preferences(&auth.username).await?;
Ok(Json(preferences))
}
@ -410,7 +398,9 @@ async fn put_preferences(
auth: Auth,
preferences: Json<user::Preferences>,
) -> Result<HttpResponse, APIError> {
block(move || user_manager.write_preferences(&auth.username, &preferences)).await?;
user_manager
.write_preferences(&auth.username, &preferences)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -429,18 +419,18 @@ async fn login(
credentials: Json<dto::Credentials>,
) -> Result<HttpResponse, APIError> {
let username = credentials.username.clone();
let (user::AuthToken(token), is_admin) =
block(move || -> Result<(user::AuthToken, bool), APIError> {
let auth_token = user_manager.login(&credentials.username, &credentials.password)?;
let is_admin = user_manager.is_admin(&credentials.username)?;
Ok((auth_token, is_admin))
})
let user::AuthToken(token) = user_manager
.login(&credentials.username, &credentials.password)
.await?;
let is_admin = user_manager.is_admin(&credentials.username).await?;
let authorization = dto::Authorization {
username: username.clone(),
token,
is_admin,
};
let response = HttpResponse::Ok().json(authorization);
Ok(response)
}
@ -450,7 +440,7 @@ async fn browse_root(
index: Data<Index>,
_auth: Auth,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = block(move || index.browse(Path::new(""))).await?;
let result = index.browse(Path::new("")).await?;
Ok(Json(result))
}
@ -460,17 +450,14 @@ async fn browse(
_auth: Auth,
path: web::Path<String>,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = block(move || {
let path = percent_decode_str(&path).decode_utf8_lossy();
index.browse(Path::new(path.as_ref()))
})
.await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let result = index.browse(Path::new(path.as_ref())).await?;
Ok(Json(result))
}
#[get("/flatten")]
async fn flatten_root(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>, APIError> {
let songs = block(move || index.flatten(Path::new(""))).await?;
let songs = index.flatten(Path::new("")).await?;
Ok(Json(songs))
}
@ -480,23 +467,20 @@ async fn flatten(
_auth: Auth,
path: web::Path<String>,
) -> Result<Json<Vec<index::Song>>, APIError> {
let songs = block(move || {
let path = percent_decode_str(&path).decode_utf8_lossy();
index.flatten(Path::new(path.as_ref()))
})
.await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let songs = index.flatten(Path::new(path.as_ref())).await?;
Ok(Json(songs))
}
#[get("/random")]
async fn random(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
let result = block(move || index.get_random_albums(20)).await?;
let result = index.get_random_albums(20).await?;
Ok(Json(result))
}
#[get("/recent")]
async fn recent(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
let result = block(move || index.get_recent_albums(20)).await?;
let result = index.get_recent_albums(20).await?;
Ok(Json(result))
}
@ -505,7 +489,7 @@ async fn search_root(
index: Data<Index>,
_auth: Auth,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = block(move || index.search("")).await?;
let result = index.search("").await?;
Ok(Json(result))
}
@ -515,7 +499,7 @@ async fn search(
_auth: Auth,
query: web::Path<String>,
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
let result = block(move || index.search(&query)).await?;
let result = index.search(&query).await?;
Ok(Json(result))
}
@ -525,13 +509,9 @@ async fn get_audio(
_auth: Auth,
path: web::Path<String>,
) -> Result<MediaFile, APIError> {
let audio_path = block(move || {
let vfs = vfs_manager.get_vfs()?;
let path = percent_decode_str(&path).decode_utf8_lossy();
vfs.virtual_to_real(Path::new(path.as_ref()))
})
.await?;
let vfs = vfs_manager.get_vfs().await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let audio_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
let named_file = NamedFile::open(audio_path).map_err(|_| APIError::AudioFileIOError)?;
Ok(MediaFile::new(named_file))
}
@ -545,19 +525,11 @@ async fn get_thumbnail(
options_input: web::Query<dto::ThumbnailOptions>,
) -> Result<MediaFile, APIError> {
let options = thumbnail::Options::from(options_input.0);
let thumbnail_path = block(move || -> Result<PathBuf, APIError> {
let vfs = vfs_manager.get_vfs()?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let image_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
thumbnails_manager
.get_thumbnail(&image_path, &options)
.map_err(|e| e.into())
})
.await?;
let vfs = vfs_manager.get_vfs().await?;
let path = percent_decode_str(&path).decode_utf8_lossy();
let image_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
let thumbnail_path = thumbnails_manager.get_thumbnail(&image_path, &options)?;
let named_file = NamedFile::open(thumbnail_path).map_err(|_| APIError::ThumbnailFileIOError)?;
Ok(MediaFile::new(named_file))
}
@ -566,7 +538,7 @@ async fn list_playlists(
playlist_manager: Data<playlist::Manager>,
auth: Auth,
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>, APIError> {
let playlist_names = block(move || playlist_manager.list_playlists(&auth.username)).await?;
let playlist_names = playlist_manager.list_playlists(&auth.username).await?;
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
.into_iter()
.map(|p| dto::ListPlaylistsEntry { name: p })
@ -582,7 +554,9 @@ async fn save_playlist(
name: web::Path<String>,
playlist: Json<dto::SavePlaylistInput>,
) -> Result<HttpResponse, APIError> {
block(move || playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)).await?;
playlist_manager
.save_playlist(&name, &auth.username, &playlist.tracks)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -592,7 +566,9 @@ async fn read_playlist(
auth: Auth,
name: web::Path<String>,
) -> Result<Json<Vec<index::Song>>, APIError> {
let songs = block(move || playlist_manager.read_playlist(&name, &auth.username)).await?;
let songs = playlist_manager
.read_playlist(&name, &auth.username)
.await?;
Ok(Json(songs))
}
@ -602,7 +578,9 @@ async fn delete_playlist(
auth: Auth,
name: web::Path<String>,
) -> Result<HttpResponse, APIError> {
block(move || playlist_manager.delete_playlist(&name, &auth.username)).await?;
playlist_manager
.delete_playlist(&name, &auth.username)
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -613,15 +591,13 @@ async fn lastfm_now_playing(
auth: Auth,
path: web::Path<String>,
) -> Result<HttpResponse, APIError> {
block(move || -> Result<(), APIError> {
if !user_manager.is_lastfm_linked(&auth.username) {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager.now_playing(&auth.username, Path::new(path.as_ref()))?;
Ok(())
})
.await?;
if !user_manager.is_lastfm_linked(&auth.username).await {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager
.now_playing(&auth.username, Path::new(path.as_ref()))
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -632,15 +608,13 @@ async fn lastfm_scrobble(
auth: Auth,
path: web::Path<String>,
) -> Result<HttpResponse, APIError> {
block(move || -> Result<(), APIError> {
if !user_manager.is_lastfm_linked(&auth.username) {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager.scrobble(&auth.username, Path::new(path.as_ref()))?;
Ok(())
})
.await?;
if !user_manager.is_lastfm_linked(&auth.username).await {
return Err(APIError::LastFMAccountNotLinked);
}
let path = percent_decode_str(&path).decode_utf8_lossy();
lastfm_manager
.scrobble(&auth.username, Path::new(path.as_ref()))
.await?;
Ok(HttpResponse::new(StatusCode::OK))
}
@ -649,8 +623,7 @@ async fn lastfm_link_token(
lastfm_manager: Data<lastfm::Manager>,
auth: Auth,
) -> Result<Json<dto::LastFMLinkToken>, APIError> {
let user::AuthToken(value) =
block(move || lastfm_manager.generate_link_token(&auth.username)).await?;
let user::AuthToken(value) = lastfm_manager.generate_link_token(&auth.username)?;
Ok(Json(dto::LastFMLinkToken { value }))
}
@ -660,27 +633,27 @@ async fn lastfm_link(
user_manager: Data<user::Manager>,
payload: web::Query<dto::LastFMLink>,
) -> Result<HttpResponse, APIError> {
let popup_content_string = block(move || {
let auth_token = user::AuthToken(payload.auth_token.clone());
let authorization =
user_manager.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)?;
let lastfm_token = &payload.token;
lastfm_manager.link(&authorization.username, lastfm_token)?;
let auth_token = user::AuthToken(payload.auth_token.clone());
let authorization = user_manager
.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)
.await?;
let lastfm_token = &payload.token;
lastfm_manager
.link(&authorization.username, lastfm_token)
.await?;
// Percent decode
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
// Percent decode
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
// Base64 decode
let popup_content = BASE64_STANDARD_NO_PAD
.decode(base64_content.as_bytes())
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
// Base64 decode
let popup_content = BASE64_STANDARD_NO_PAD
.decode(base64_content.as_bytes())
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
// UTF-8 decode
str::from_utf8(&popup_content)
.map_err(|_| APIError::LastFMLinkContentEncodingError)
.map(|s| s.to_owned())
})
.await?;
// UTF-8 decode
let popup_content_string = str::from_utf8(&popup_content)
.map_err(|_| APIError::LastFMLinkContentEncodingError)
.map(|s| s.to_owned())?;
Ok(HttpResponse::build(StatusCode::OK)
.content_type("text/html; charset=utf-8")
@ -692,6 +665,6 @@ async fn lastfm_unlink(
lastfm_manager: Data<lastfm::Manager>,
auth: Auth,
) -> Result<HttpResponse, APIError> {
block(move || lastfm_manager.unlink(&auth.username)).await?;
lastfm_manager.unlink(&auth.username).await?;
Ok(HttpResponse::new(StatusCode::OK))
}

View file

@ -1,7 +1,6 @@
use actix_test::TestServer;
use actix_web::{
middleware::{Compress, Logger},
rt::{System, SystemRunner},
web::Bytes,
App as ActixApp,
};
@ -18,7 +17,6 @@ use crate::service::test::TestService;
use crate::test::*;
pub struct ActixTestService {
system_runner: SystemRunner,
authorization: Option<dto::Authorization>,
server: TestServer,
}
@ -26,7 +24,7 @@ pub struct ActixTestService {
pub type ServiceType = ActixTestService;
impl ActixTestService {
fn process_internal<T: Serialize + Clone + 'static>(
async fn process_internal<T: Serialize + Clone + 'static>(
&mut self,
request: &Request<T>,
) -> (Builder, Option<Bytes>) {
@ -50,9 +48,7 @@ impl ActixTestService {
actix_request = actix_request.bearer_auth(&authorization.token);
}
let mut actix_response = self
.system_runner
.block_on(async move { actix_request.send_json(&body).await.unwrap() });
let mut actix_response = actix_request.send_json(&body).await.unwrap();
let mut response_builder = Response::builder().status(actix_response.status());
let headers = response_builder.headers_mut().unwrap();
@ -62,10 +58,7 @@ impl ActixTestService {
let is_success = actix_response.status().is_success();
let body = if is_success {
Some(
self.system_runner
.block_on(async move { actix_response.body().await.unwrap() }),
)
Some(actix_response.body().await.unwrap())
} else {
None
};
@ -75,7 +68,7 @@ impl ActixTestService {
}
impl TestService for ActixTestService {
fn new(test_name: &str) -> Self {
async fn new(test_name: &str) -> Self {
let output_dir = prepare_test_directory(test_name);
let paths = Paths {
@ -89,9 +82,8 @@ impl TestService for ActixTestService {
web_dir_path: ["test-data", "web"].iter().collect(),
};
let app = App::new(5050, paths).unwrap();
let app = App::new(5050, paths).await.unwrap();
let system_runner = System::new();
let server = actix_test::start(move || {
let config = make_config(app.clone());
ActixApp::new()
@ -102,31 +94,33 @@ impl TestService for ActixTestService {
ActixTestService {
authorization: None,
system_runner,
server,
}
}
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()> {
let (response_builder, _body) = self.process_internal(request);
async fn fetch<T: Serialize + Clone + 'static>(
&mut self,
request: &Request<T>,
) -> Response<()> {
let (response_builder, _body) = self.process_internal(request).await;
response_builder.body(()).unwrap()
}
fn fetch_bytes<T: Serialize + Clone + 'static>(
async fn fetch_bytes<T: Serialize + Clone + 'static>(
&mut self,
request: &Request<T>,
) -> Response<Vec<u8>> {
let (response_builder, body) = self.process_internal(request);
let (response_builder, body) = self.process_internal(request).await;
response_builder
.body(body.unwrap().deref().to_owned())
.unwrap()
}
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
async fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
&mut self,
request: &Request<T>,
) -> Response<U> {
let (response_builder, body) = self.process_internal(request);
let (response_builder, body) = self.process_internal(request).await;
let body = serde_json::from_slice(&body.unwrap()).unwrap();
response_builder.body(body).unwrap()
}

View file

@ -139,9 +139,9 @@ pub struct DDNSConfig {
impl From<DDNSConfig> for ddns::Config {
fn from(c: DDNSConfig) -> Self {
Self {
host: c.host,
username: c.username,
password: c.password,
ddns_host: c.host,
ddns_username: c.username,
ddns_password: c.password,
}
}
}
@ -149,9 +149,9 @@ impl From<DDNSConfig> for ddns::Config {
impl From<ddns::Config> for DDNSConfig {
fn from(c: ddns::Config) -> Self {
Self {
host: c.host,
username: c.username,
password: c.password,
host: c.ddns_host,
username: c.ddns_username,
password: c.ddns_password,
}
}
}
@ -204,7 +204,7 @@ impl From<Config> for config::Config {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewSettings {
pub album_art_pattern: Option<String>,
pub reindex_every_n_seconds: Option<i32>,
pub reindex_every_n_seconds: Option<i64>,
}
impl From<NewSettings> for settings::NewSettings {
@ -219,7 +219,7 @@ impl From<NewSettings> for settings::NewSettings {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Settings {
pub album_art_pattern: String,
pub reindex_every_n_seconds: i32,
pub reindex_every_n_seconds: i64,
}
impl From<settings::Settings> for Settings {

View file

@ -18,7 +18,7 @@ pub enum APIError {
#[error("Could not encode Branca token")]
BrancaTokenEncoding,
#[error("Database error:\n\n{0}")]
Database(diesel::result::Error),
Database(sqlx::Error),
#[error("DDNS update query failed with HTTP status {0}")]
DdnsUpdateQueryFailed(u16),
#[error("Cannot delete your own account")]

View file

@ -26,18 +26,19 @@ use crate::service::test::constants::*;
pub use crate::service::actix::test::ServiceType;
pub trait TestService {
fn new(test_name: &str) -> Self;
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()>;
fn fetch_bytes<T: Serialize + Clone + 'static>(
async fn new(test_name: &str) -> Self;
async fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>)
-> Response<()>;
async fn fetch_bytes<T: Serialize + Clone + 'static>(
&mut self,
request: &Request<T>,
) -> Response<Vec<u8>>;
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
async fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
&mut self,
request: &Request<T>,
) -> Response<U>;
fn complete_initial_setup(&mut self) {
async fn complete_initial_setup(&mut self) {
let configuration = dto::Config {
users: Some(vec![
dto::NewUser {
@ -58,40 +59,43 @@ pub trait TestService {
..Default::default()
};
let request = protocol::apply_config(configuration);
let response = self.fetch(&request);
let response = self.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
fn login_internal(&mut self, username: &str, password: &str) {
async fn login_internal(&mut self, username: &str, password: &str) {
let request = protocol::login(username, password);
let response = self.fetch_json::<_, dto::Authorization>(&request);
let response = self.fetch_json::<_, dto::Authorization>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let authorization = response.into_body();
self.set_authorization(Some(authorization));
}
fn login_admin(&mut self) {
self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN);
async fn login_admin(&mut self) {
self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN)
.await;
}
fn login(&mut self) {
self.login_internal(TEST_USERNAME, TEST_PASSWORD);
async fn login(&mut self) {
self.login_internal(TEST_USERNAME, TEST_PASSWORD).await;
}
fn logout(&mut self) {
async fn logout(&mut self) {
self.set_authorization(None);
}
fn set_authorization(&mut self, authorization: Option<dto::Authorization>);
fn index(&mut self) {
async fn index(&mut self) {
let request = protocol::trigger_index();
let response = self.fetch(&request);
let response = self.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
loop {
let browse_request = protocol::browse(Path::new(""));
let response = self.fetch_json::<(), Vec<index::CollectionFile>>(&browse_request);
let response = self
.fetch_json::<(), Vec<index::CollectionFile>>(&browse_request)
.await;
let entries = response.body();
if !entries.is_empty() {
break;
@ -101,7 +105,9 @@ pub trait TestService {
loop {
let flatten_request = protocol::flatten(Path::new(""));
let response = self.fetch_json::<_, Vec<index::Song>>(&flatten_request);
let response = self
.fetch_json::<_, Vec<index::Song>>(&flatten_request)
.await;
let entries = response.body();
if !entries.is_empty() {
break;

View file

@ -5,20 +5,20 @@ use crate::service::dto;
use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn returns_api_version() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn returns_api_version() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::version();
let response = service.fetch_json::<_, dto::Version>(&request);
let response = service.fetch_json::<_, dto::Version>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn initial_setup_golden_path() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn initial_setup_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::initial_setup();
{
let response = service.fetch_json::<_, dto::InitialSetup>(&request);
let response = service.fetch_json::<_, dto::InitialSetup>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let initial_setup = response.body();
assert_eq!(
@ -28,9 +28,9 @@ fn initial_setup_golden_path() {
}
);
}
service.complete_initial_setup();
service.complete_initial_setup().await;
{
let response = service.fetch_json::<_, dto::InitialSetup>(&request);
let response = service.fetch_json::<_, dto::InitialSetup>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let initial_setup = response.body();
assert_eq!(
@ -42,40 +42,44 @@ fn initial_setup_golden_path() {
}
}
#[test]
fn trigger_index_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn trigger_index_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::random();
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
let entries = response.body();
assert_eq!(entries.len(), 0);
service.index();
service.index().await;
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[test]
fn trigger_index_requires_auth() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn trigger_index_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::trigger_index();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn trigger_index_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn trigger_index_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::trigger_index();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

View file

@ -5,33 +5,33 @@ use crate::service::dto;
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn login_rejects_bad_username() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn login_rejects_bad_username() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::login("garbage", TEST_PASSWORD);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn login_rejects_bad_password() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn login_rejects_bad_password() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::login(TEST_USERNAME, "garbage");
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn login_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn login_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
let response = service.fetch_json::<_, dto::Authorization>(&request);
let response = service.fetch_json::<_, dto::Authorization>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let authorization = response.body();
@ -40,73 +40,73 @@ fn login_golden_path() {
assert!(!authorization.token.is_empty());
}
#[test]
fn authentication_via_bearer_http_header_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn authentication_via_bearer_http_header_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let mut request = protocol::random();
let bearer = headers::Authorization::bearer("garbage").unwrap();
request.headers_mut().typed_insert(bearer);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn authentication_via_bearer_http_header_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn authentication_via_bearer_http_header_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let authorization = {
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
let response = service.fetch_json::<_, dto::Authorization>(&request);
let response = service.fetch_json::<_, dto::Authorization>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
response.into_body()
};
service.logout();
service.logout().await;
let mut request = protocol::random();
let bearer = headers::Authorization::bearer(&authorization.token).unwrap();
request.headers_mut().typed_insert(bearer);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn authentication_via_query_param_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn authentication_via_query_param_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let mut request = protocol::random();
*request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token")
.parse()
.unwrap();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn authentication_via_query_param_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn authentication_via_query_param_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let authorization = {
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
let response = service.fetch_json::<_, dto::Authorization>(&request);
let response = service.fetch_json::<_, dto::Authorization>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
response.into_body()
};
service.logout();
service.logout().await;
let mut request = protocol::random();
*request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token)
.parse()
.unwrap();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}

View file

@ -5,214 +5,230 @@ use crate::app::index;
use crate::service::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn browse_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn browse_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::browse(&PathBuf::new());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn browse_root() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn browse_root() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::browse(&PathBuf::new());
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
let response = service
.fetch_json::<_, Vec<index::CollectionFile>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 1);
}
#[test]
fn browse_directory() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn browse_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::browse(&path);
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
let response = service
.fetch_json::<_, Vec<index::CollectionFile>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 5);
}
#[test]
fn browse_bad_directory() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn browse_bad_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::browse(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn flatten_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn flatten_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::flatten(&PathBuf::new());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn flatten_root() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn flatten_root() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::flatten(&PathBuf::new());
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
let response = service.fetch_json::<_, Vec<index::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 13);
}
#[test]
fn flatten_directory() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn flatten_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::flatten(Path::new(TEST_MOUNT_NAME));
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
let response = service.fetch_json::<_, Vec<index::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 13);
}
#[test]
fn flatten_bad_directory() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn flatten_bad_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::flatten(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn random_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn random_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::random();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn random_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn random_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::random();
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[test]
fn random_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn random_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let mut request = protocol::random();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[test]
fn recent_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn recent_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::recent();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn recent_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn recent_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::recent();
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[test]
fn recent_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn recent_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let mut request = protocol::recent();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
let response = service
.fetch_json::<_, Vec<index::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[test]
fn search_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn search_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::search("");
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn search_without_query() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn search_without_query() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::search("");
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
let response = service
.fetch_json::<_, Vec<index::CollectionFile>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn search_with_query() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn search_with_query() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::search("door");
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
let response = service
.fetch_json::<_, Vec<index::CollectionFile>>(&request)
.await;
let results = response.body();
assert_eq!(results.len(), 1);
match results[0] {

View file

@ -4,60 +4,60 @@ use crate::service::dto;
use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn get_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn get_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::get_ddns_config();
service.complete_initial_setup();
service.complete_initial_setup().await;
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn get_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn get_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::get_ddns_config();
let response = service.fetch_json::<_, dto::DDNSConfig>(&request);
let response = service.fetch_json::<_, dto::DDNSConfig>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn put_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn put_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::put_ddns_config(dto::DDNSConfig {
host: "test".to_owned(),
username: "test".to_owned(),
password: "test".to_owned(),
});
service.complete_initial_setup();
service.complete_initial_setup().await;
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn put_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn put_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::put_ddns_config(dto::DDNSConfig {
host: "test".to_owned(),
username: "test".to_owned(),
password: "test".to_owned(),
});
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}

View file

@ -5,56 +5,58 @@ use crate::service::dto;
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn lastfm_scrobble_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn lastfm_scrobble_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
.collect();
let request = protocol::lastfm_scrobble(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[test]
fn lastfm_now_playing_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn lastfm_now_playing_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
.collect();
let request = protocol::lastfm_now_playing(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[test]
fn lastfm_link_token_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn lastfm_link_token_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::lastfm_link_token();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn lastfm_link_token_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn lastfm_link_token_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::lastfm_link_token();
let response = service.fetch_json::<_, dto::LastFMLinkToken>(&request);
let response = service
.fetch_json::<_, dto::LastFMLinkToken>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let link_token = response.body();
assert!(!link_token.value.is_empty());

View file

@ -5,33 +5,33 @@ use crate::service::dto::ThumbnailSize;
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn audio_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn audio_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
.collect();
let request = protocol::audio(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn audio_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn audio_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
.collect();
let request = protocol::audio(&path);
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().len(), 24_142);
assert_eq!(
@ -40,13 +40,13 @@ fn audio_golden_path() {
);
}
#[test]
fn audio_does_not_encode_content() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn audio_does_not_encode_content() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
@ -59,7 +59,7 @@ fn audio_does_not_encode_content() {
HeaderValue::from_str("gzip, deflate, br").unwrap(),
);
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().len(), 24_142);
assert_eq!(response.headers().get(header::TRANSFER_ENCODING), None);
@ -69,13 +69,13 @@ fn audio_does_not_encode_content() {
);
}
#[test]
fn audio_partial_content() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn audio_partial_content() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter()
@ -88,7 +88,7 @@ fn audio_partial_content() {
HeaderValue::from_str("bytes=100-299").unwrap(),
);
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.body().len(), 200);
assert_eq!(
@ -97,22 +97,22 @@ fn audio_partial_content() {
);
}
#[test]
fn audio_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn audio_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::audio(&path);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn thumbnail_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn thumbnail_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"]
.iter()
@ -121,17 +121,17 @@ fn thumbnail_requires_auth() {
let size = None;
let pad = None;
let request = protocol::thumbnail(&path, size, pad);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn thumbnail_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
#[actix_web::test]
async fn thumbnail_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"]
.iter()
@ -140,60 +140,60 @@ fn thumbnail_golden_path() {
let size = None;
let pad = None;
let request = protocol::thumbnail(&path, size, pad);
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn thumbnail_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn thumbnail_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let size = None;
let pad = None;
let request = protocol::thumbnail(&path, size, pad);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn thumbnail_size_default() {
thumbnail_size(&test_name!(), None, None, 400);
#[actix_web::test]
async fn thumbnail_size_default() {
thumbnail_size(&test_name!(), None, None, 400).await;
}
#[test]
fn thumbnail_size_small() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Small), None, 400);
#[actix_web::test]
async fn thumbnail_size_small() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Small), None, 400).await;
}
#[test]
#[actix_web::test]
#[cfg(not(tarpaulin))]
fn thumbnail_size_large() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Large), None, 1200);
async fn thumbnail_size_large() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Large), None, 1200).await;
}
#[test]
#[actix_web::test]
#[cfg(not(tarpaulin))]
fn thumbnail_size_native() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Native), None, 1423);
async fn thumbnail_size_native() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Native), None, 1423).await;
}
fn thumbnail_size(name: &str, size: Option<ThumbnailSize>, pad: Option<bool>, expected: u32) {
let mut service = ServiceType::new(name);
service.complete_initial_setup();
service.login_admin();
service.index();
service.login();
async fn thumbnail_size(name: &str, size: Option<ThumbnailSize>, pad: Option<bool>, expected: u32) {
let mut service = ServiceType::new(name).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic", "Folder.png"]
.iter()
.collect();
let request = protocol::thumbnail(&path, size, pad);
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let thumbnail = image::load_from_memory(response.body()).unwrap().to_rgb8();
assert_eq!(thumbnail.width(), expected);

View file

@ -5,130 +5,132 @@ use crate::service::dto;
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn list_playlists_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn list_playlists_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::playlists();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn list_playlists_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn list_playlists_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::playlists();
let response = service.fetch_json::<_, Vec<dto::ListPlaylistsEntry>>(&request);
let response = service
.fetch_json::<_, Vec<dto::ListPlaylistsEntry>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn save_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn save_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn save_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn save_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn save_playlist_large() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn save_playlist_large() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let tracks = (0..100_000)
.map(|_| "My Super Cool Song".to_string())
.collect();
let my_playlist = dto::SavePlaylistInput { tracks };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn get_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn get_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn get_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn get_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
{
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
let response = service.fetch_json::<_, Vec<index::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn get_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn get_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn delete_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn delete_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn delete_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn delete_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
{
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn delete_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn delete_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View file

@ -4,72 +4,72 @@ use crate::service::dto::{self, Settings};
use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn get_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn get_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::get_settings();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn get_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn get_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::get_settings();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn get_settings_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn get_settings_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::get_settings();
let response = service.fetch_json::<_, dto::Settings>(&request);
let response = service.fetch_json::<_, dto::Settings>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn put_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn put_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::put_settings(dto::NewSettings::default());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn put_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn put_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::put_settings(dto::NewSettings::default());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn put_settings_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn put_settings_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::put_settings(dto::NewSettings {
album_art_pattern: Some("test_pattern".to_owned()),
reindex_every_n_seconds: Some(31),
});
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let request = protocol::get_settings();
let response = service.fetch_json::<_, dto::Settings>(&request);
let response = service.fetch_json::<_, dto::Settings>(&request).await;
let settings = response.body();
assert_eq!(
settings,

View file

@ -3,19 +3,19 @@ use http::StatusCode;
use crate::service::test::{add_trailing_slash, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn can_get_swagger_index() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn can_get_swagger_index() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::swagger_index();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn can_get_swagger_index_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn can_get_swagger_index_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!()).await;
let mut request = protocol::swagger_index();
add_trailing_slash(&mut request);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}

View file

@ -6,53 +6,53 @@ use crate::service::dto;
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn list_users_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn list_users_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::list_users();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn list_users_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn list_users_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::list_users();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn create_user_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn create_user_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::create_user(dto::NewUser {
name: "Walter".into(),
password: "secret".into(),
admin: false,
});
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn create_user_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login_admin();
#[actix_web::test]
async fn create_user_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
let new_user = dto::NewUser {
name: "Walter".into(),
@ -60,39 +60,39 @@ fn create_user_golden_path() {
admin: false,
};
let request = protocol::create_user(new_user);
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn update_user_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn update_user_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::update_user("Walter", dto::UserUpdate::default());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn update_user_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn update_user_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::update_user("Walter", dto::UserUpdate::default());
service.login_admin();
let response = service.fetch(&request);
service.login_admin().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn update_user_cannot_unadmin_self() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn update_user_cannot_unadmin_self() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::update_user(
TEST_USERNAME_ADMIN,
dto::UserUpdate {
@ -101,80 +101,80 @@ fn update_user_cannot_unadmin_self() {
},
);
service.login_admin();
let response = service.fetch(&request);
service.login_admin().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[test]
fn delete_user_requires_admin() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn delete_user_requires_admin() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::delete_user("Walter");
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login();
let response = service.fetch(&request);
service.login().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn delete_user_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn delete_user_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::delete_user("Walter");
service.login_admin();
let response = service.fetch(&request);
service.login_admin().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn delete_user_cannot_delete_self() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
#[actix_web::test]
async fn delete_user_cannot_delete_self() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let request = protocol::delete_user(TEST_USERNAME_ADMIN);
service.login_admin();
let response = service.fetch(&request);
service.login_admin().await;
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[test]
fn get_preferences_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn get_preferences_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::get_preferences();
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn get_preferences_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn get_preferences_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::get_preferences();
let response = service.fetch_json::<_, user::Preferences>(&request);
let response = service.fetch_json::<_, user::Preferences>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn put_preferences_requires_auth() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn put_preferences_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::put_preferences(user::Preferences::default());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn put_preferences_golden_path() {
let mut service = ServiceType::new(&test_name!());
service.complete_initial_setup();
service.login();
#[actix_web::test]
async fn put_preferences_golden_path() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let request = protocol::put_preferences(user::Preferences::default());
let response = service.fetch(&request);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}

View file

@ -3,10 +3,10 @@ use http::StatusCode;
use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name;
#[test]
fn serves_web_client() {
let mut service = ServiceType::new(&test_name!());
#[actix_web::test]
async fn serves_web_client() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::web_index();
let response = service.fetch_bytes(&request);
let response = service.fetch_bytes(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}

View file

@ -1,6 +0,0 @@
cargo install diesel_cli --no-default-features --features "sqlite-bundled"
mkdir tmp
diesel --database-url tmp/print-schema.sqlite setup
diesel --database-url tmp/print-schema.sqlite migration run
rmdir /q /s tmp