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 # Runtime artifacts
*.sqlite *.sqlite
**/*.sqlite-shm
**/*.sqlite-wal
polaris.log polaris.log
polaris.pid polaris.pid
/thumbnails /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" build = "build.rs"
[features] [features]
default = ["bundle-sqlite"]
bundle-sqlite = ["libsqlite3-sys"]
ui = ["native-windows-gui", "native-windows-derive"] ui = ["native-windows-gui", "native-windows-derive"]
[dependencies] [dependencies]
@ -18,16 +16,11 @@ ape = "0.5"
base64 = "0.21" base64 = "0.21"
branca = "0.10.1" branca = "0.10.1"
crossbeam-channel = "0.5" crossbeam-channel = "0.5"
diesel_migrations = { version = "2.0", features = ["sqlite"] }
futures-util = { version = "0.3" } futures-util = { version = "0.3" }
getopts = "0.2.21" getopts = "0.2.21"
http = "0.2.8" http = "0.2.8"
id3 = "1.7.0" id3 = "1.7.0"
lewton = "0.10.2" lewton = "0.10.2"
libsqlite3-sys = { version = "0.26", features = [
"bundled",
"bundled-windows",
], optional = true }
log = "0.4.17" log = "0.4.17"
metaflac = "0.2.5" metaflac = "0.2.5"
mp3-duration = "0.1.10" mp3-duration = "0.1.10"
@ -44,19 +37,16 @@ serde = { version = "1.0.147", features = ["derive"] }
serde_derive = "1.0.147" serde_derive = "1.0.147"
serde_json = "1.0.87" serde_json = "1.0.87"
simplelog = "0.12.0" simplelog = "0.12.0"
sqlx = { version = "0.7.4", features = ["migrate", "runtime-tokio", "sqlite"] }
thiserror = "1.0.37" thiserror = "1.0.37"
tokio = { version = "1.38", features = ["macros", "rt-multi-thread"] }
toml = "0.7" toml = "0.7"
ureq = "2.7" ureq = "2.7"
url = "2.3" url = "2.3"
[dependencies.diesel]
version = "2.0.2"
default_features = false
features = ["libsqlite3-sys", "r2d2", "sqlite"]
[dependencies.image] [dependencies.image]
version = "0.24.4" version = "0.24.4"
default_features = false default-features = false
features = ["bmp", "gif", "jpeg", "png"] features = ["bmp", "gif", "jpeg", "png"]
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
@ -81,3 +71,6 @@ winres = "0.1"
actix-test = "0.1.0" actix-test = "0.1.0"
headers = "0.3" headers = "0.3"
fs_extra = "1.2.0" 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**) - Input a user-facing version name (eg: **0.13.0**)
- Click the **Run workflow** button - Click the **Run workflow** button
- After CI completes, move the release from Draft to Published - 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)] #[derive(Clone)]
pub struct App { pub struct App {
pub port: u16, pub port: u16,
pub auth_secret: settings::AuthSecret,
pub web_dir_path: PathBuf, pub web_dir_path: PathBuf,
pub swagger_dir_path: PathBuf, pub swagger_dir_path: PathBuf,
pub db: DB,
pub index: index::Index, pub index: index::Index,
pub config_manager: config::Manager, pub config_manager: config::Manager,
pub ddns_manager: ddns::Manager, pub ddns_manager: ddns::Manager,
@ -48,8 +46,8 @@ pub struct App {
} }
impl App { impl App {
pub fn new(port: u16, paths: Paths) -> Result<Self, Error> { pub async fn new(port: u16, paths: Paths) -> Result<Self, Error> {
let db = DB::new(&paths.db_file_path)?; let db = DB::new(&paths.db_file_path).await?;
fs::create_dir_all(&paths.web_dir_path) fs::create_dir_all(&paths.web_dir_path)
.map_err(|e| Error::Io(paths.web_dir_path.clone(), e))?; .map_err(|e| Error::Io(paths.web_dir_path.clone(), e))?;
fs::create_dir_all(&paths.swagger_dir_path) fs::create_dir_all(&paths.swagger_dir_path)
@ -61,7 +59,7 @@ impl App {
let vfs_manager = vfs::Manager::new(db.clone()); let vfs_manager = vfs::Manager::new(db.clone());
let settings_manager = settings::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 ddns_manager = ddns::Manager::new(db.clone());
let user_manager = user::Manager::new(db.clone(), auth_secret); let user_manager = user::Manager::new(db.clone(), auth_secret);
let index = index::Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone()); 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 { if let Some(config_path) = paths.config_file_path {
let config = config::Config::from_path(&config_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 { Ok(Self {
port, port,
auth_secret,
web_dir_path: paths.web_dir_path, web_dir_path: paths.web_dir_path,
swagger_dir_path: paths.swagger_dir_path, swagger_dir_path: paths.swagger_dir_path,
index, index,
@ -96,7 +91,6 @@ impl App {
thumbnail_manager, thumbnail_manager,
user_manager, user_manager,
vfs_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 { 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 { 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 { 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 { 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 // Delete users that are not in new list
for old_user in old_users for old_user in old_users
.iter() .iter()
.filter(|old_user| !users.iter().any(|u| u.name == old_user.name)) .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 // Insert new users
@ -93,13 +93,17 @@ impl Manager {
.iter() .iter()
.filter(|u| !old_users.iter().any(|old_user| old_user.name == u.name)) .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 // Update users
for user in users { for user in users {
self.user_manager.set_password(&user.name, &user.password)?; self.user_manager
self.user_manager.set_is_admin(&user.name, user.admin)?; .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::app::test;
use crate::test_name; use crate::test_name;
#[test] #[tokio::test]
fn apply_saves_misc_settings() { async fn apply_saves_misc_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build(); let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config { let new_config = Config {
settings: Some(settings::NewSettings { settings: Some(settings::NewSettings {
album_art_pattern: Some("🖼️\\.jpg".into()), album_art_pattern: Some("🖼️\\.jpg".into()),
@ -125,8 +129,8 @@ mod test {
..Default::default() ..Default::default()
}; };
ctx.config_manager.apply(&new_config).unwrap(); ctx.config_manager.apply(&new_config).await.unwrap();
let settings = ctx.settings_manager.read().unwrap(); let settings = ctx.settings_manager.read().await.unwrap();
let new_settings = new_config.settings.unwrap(); let new_settings = new_config.settings.unwrap();
assert_eq!( assert_eq!(
settings.index_album_art_pattern, settings.index_album_art_pattern,
@ -138,9 +142,9 @@ mod test {
); );
} }
#[test] #[tokio::test]
fn apply_saves_mount_points() { async fn apply_saves_mount_points() {
let ctx = test::ContextBuilder::new(test_name!()).build(); let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config { let new_config = Config {
mount_dirs: Some(vec![vfs::MountDir { mount_dirs: Some(vec![vfs::MountDir {
@ -150,36 +154,37 @@ mod test {
..Default::default() ..Default::default()
}; };
ctx.config_manager.apply(&new_config).unwrap(); ctx.config_manager.apply(&new_config).await.unwrap();
let actual_mount_dirs: Vec<vfs::MountDir> = ctx.vfs_manager.mount_dirs().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()); assert_eq!(actual_mount_dirs, new_config.mount_dirs.unwrap());
} }
#[test] #[tokio::test]
fn apply_saves_ddns_settings() { async fn apply_saves_ddns_settings() {
let ctx = test::ContextBuilder::new(test_name!()).build(); let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config { let new_config = Config {
ydns: Some(ddns::Config { ydns: Some(ddns::Config {
host: "🐸🐸🐸.ydns.eu".into(), ddns_host: "🐸🐸🐸.ydns.eu".into(),
username: "kfr🐸g".into(), ddns_username: "kfr🐸g".into(),
password: "tasty🐞".into(), ddns_password: "tasty🐞".into(),
}), }),
..Default::default() ..Default::default()
}; };
ctx.config_manager.apply(&new_config).unwrap(); ctx.config_manager.apply(&new_config).await.unwrap();
let actual_ddns = ctx.ddns_manager.config().unwrap(); let actual_ddns = ctx.ddns_manager.config().await.unwrap();
assert_eq!(actual_ddns, new_config.ydns.unwrap()); assert_eq!(actual_ddns, new_config.ydns.unwrap());
} }
#[test] #[tokio::test]
fn apply_can_toggle_admin() { async fn apply_can_toggle_admin() {
let ctx = test::ContextBuilder::new(test_name!()) let ctx = test::ContextBuilder::new(test_name!())
.user("Walter", "Tasty🍖", true) .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 { let new_config = Config {
users: Some(vec![user::NewUser { users: Some(vec![user::NewUser {
@ -189,7 +194,7 @@ mod test {
}]), }]),
..Default::default() ..Default::default()
}; };
ctx.config_manager.apply(&new_config).unwrap(); ctx.config_manager.apply(&new_config).await.unwrap();
assert!(!ctx.user_manager.list().unwrap()[0].is_admin()); assert!(!ctx.user_manager.list().await.unwrap()[0].is_admin());
} }
} }

View file

@ -1,11 +1,9 @@
use base64::prelude::*; use base64::prelude::*;
use diesel::prelude::*;
use log::{debug, error}; use log::{debug, error};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::thread; use std::time::Duration;
use std::time;
use crate::db::{self, ddns_config, DB}; use crate::db::{self, DB};
const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/"; const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/";
@ -18,15 +16,14 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
DatabaseConnection(#[from] db::Error), DatabaseConnection(#[from] db::Error),
#[error(transparent)] #[error(transparent)]
Database(#[from] diesel::result::Error), Database(#[from] sqlx::Error),
} }
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Eq, Queryable, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[diesel(table_name = ddns_config)]
pub struct Config { pub struct Config {
pub host: String, pub ddns_host: String,
pub username: String, pub ddns_username: String,
pub password: String, pub ddns_password: String,
} }
#[derive(Clone)] #[derive(Clone)]
@ -39,15 +36,15 @@ impl Manager {
Self { db } Self { db }
} }
fn update_my_ip(&self) -> Result<(), Error> { async fn update_my_ip(&self) -> Result<(), Error> {
let config = self.config()?; let config = self.config().await?;
if config.host.is_empty() || config.username.is_empty() { if config.ddns_host.is_empty() || config.ddns_username.is_empty() {
debug!("Skipping DDNS update because credentials are missing"); debug!("Skipping DDNS update because credentials are missing");
return Ok(()); return Ok(());
} }
let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.host); let full_url = format!("{}?host={}", DDNS_UPDATE_URL, &config.ddns_host);
let credentials = format!("{}:{}", &config.username, &config.password); let credentials = format!("{}:{}", &config.ddns_username, &config.ddns_password);
let response = ureq::get(full_url.as_str()) let response = ureq::get(full_url.as_str())
.set( .set(
"Authorization", "Authorization",
@ -62,40 +59,38 @@ impl Manager {
} }
} }
pub fn config(&self) -> Result<Config, Error> { pub async fn config(&self) -> Result<Config, Error> {
use crate::db::ddns_config::dsl::*; Ok(sqlx::query_as!(
let mut connection = self.db.connect()?; Config,
Ok(ddns_config "SELECT ddns_host, ddns_username, ddns_password FROM config"
.select((host, username, password)) )
.get_result(&mut connection)?) .fetch_one(self.db.connect().await?.as_mut())
.await?)
} }
pub fn set_config(&self, new_config: &Config) -> Result<(), Error> { pub async fn set_config(&self, new_config: &Config) -> Result<(), Error> {
use crate::db::ddns_config::dsl::*; sqlx::query!(
let mut connection = self.db.connect()?; "UPDATE config SET ddns_host = $1, ddns_username = $2, ddns_password = $3",
diesel::update(ddns_config) new_config.ddns_host,
.set(( new_config.ddns_username,
host.eq(&new_config.host), new_config.ddns_password
username.eq(&new_config.username), )
password.eq(&new_config.password), .execute(self.db.connect().await?.as_mut())
)) .await?;
.execute(&mut connection)?;
Ok(()) Ok(())
} }
pub fn begin_periodic_updates(&self) { pub fn begin_periodic_updates(&self) {
let cloned = self.clone(); tokio::spawn({
std::thread::spawn(move || { let ddns = self.clone();
cloned.run(); 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 log::error;
use std::sync::{Arc, Condvar, Mutex}; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Notify;
use crate::app::{settings, vfs}; use crate::app::{settings, vfs};
use crate::db::DB; use crate::db::DB;
@ -20,7 +21,7 @@ pub struct Index {
db: DB, db: DB,
vfs_manager: vfs::Manager, vfs_manager: vfs::Manager,
settings_manager: settings::Manager, settings_manager: settings::Manager,
pending_reindex: Arc<(Mutex<bool>, Condvar)>, pending_reindex: Arc<Notify>,
} }
impl Index { impl Index {
@ -29,63 +30,45 @@ impl Index {
db, db,
vfs_manager, vfs_manager,
settings_manager, settings_manager,
pending_reindex: Arc::new(Notify::new()),
pending_reindex: Arc::new((
#[allow(clippy::mutex_atomic)]
Mutex::new(false),
Condvar::new(),
)),
}; };
let commands_index = index.clone(); tokio::spawn({
std::thread::spawn(move || { let index = index.clone();
commands_index.process_commands(); async move {
loop {
index.pending_reindex.notified().await;
if let Err(e) = index.update().await {
error!("Error while updating index: {}", e);
}
}
}
}); });
index index
} }
pub fn trigger_reindex(&self) { pub fn trigger_reindex(&self) {
let (lock, cvar) = &*self.pending_reindex; self.pending_reindex.notify_one();
let mut pending_reindex = lock.lock().unwrap();
*pending_reindex = true;
cvar.notify_one();
} }
pub fn begin_periodic_updates(&self) { pub fn begin_periodic_updates(&self) {
let auto_index = self.clone(); tokio::spawn({
std::thread::spawn(move || { let index = self.clone();
auto_index.automatic_reindex(); 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 std::path::{Path, PathBuf};
use super::*; use super::*;
use crate::db::{self, directories, songs}; use crate::db;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum QueryError { pub enum QueryError {
#[error(transparent)] #[error(transparent)]
Database(#[from] diesel::result::Error), Database(#[from] sqlx::Error),
#[error(transparent)] #[error(transparent)]
DatabaseConnection(#[from] db::Error), DatabaseConnection(#[from] db::Error),
#[error("Song was not found: `{0}`")] #[error("Song was not found: `{0}`")]
@ -18,25 +15,21 @@ pub enum QueryError {
Vfs(#[from] vfs::Error), Vfs(#[from] vfs::Error),
} }
sql_function!(
#[aggregate]
fn random() -> Integer;
);
impl Index { 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 where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let mut output = Vec::new(); let mut output = Vec::new();
let vfs = self.vfs_manager.get_vfs()?; let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect()?; let mut connection = self.db.connect().await?;
if virtual_path.as_ref().components().count() == 0 { if virtual_path.as_ref().components().count() == 0 {
// Browse top-level // Browse top-level
let real_directories: Vec<Directory> = directories::table let real_directories =
.filter(directories::parent.is_null()) sqlx::query_as!(Directory, "SELECT * FROM directories WHERE parent IS NULL")
.load(&mut connection)?; .fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories let virtual_directories = real_directories
.into_iter() .into_iter()
.filter_map(|d| d.virtualize(&vfs)); .filter_map(|d| d.virtualize(&vfs));
@ -46,19 +39,28 @@ impl Index {
let real_path = vfs.virtual_to_real(virtual_path)?; let real_path = vfs.virtual_to_real(virtual_path)?;
let real_path_string = real_path.as_path().to_string_lossy().into_owned(); let real_path_string = real_path.as_path().to_string_lossy().into_owned();
let real_directories: Vec<Directory> = directories::table let real_directories = sqlx::query_as!(
.filter(directories::parent.eq(&real_path_string)) Directory,
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC")) "SELECT * FROM directories WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
.load(&mut connection)?; real_path_string
)
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories let virtual_directories = real_directories
.into_iter() .into_iter()
.filter_map(|d| d.virtualize(&vfs)); .filter_map(|d| d.virtualize(&vfs));
output.extend(virtual_directories.map(CollectionFile::Directory)); output.extend(virtual_directories.map(CollectionFile::Directory));
let real_songs: Vec<Song> = songs::table let real_songs = sqlx::query_as!(
.filter(songs::parent.eq(&real_path_string)) Song,
.order(sql::<sql_types::Bool>("path COLLATE NOCASE ASC")) "SELECT * FROM songs WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
.load(&mut connection)?; real_path_string
)
.fetch_all(connection.as_mut())
.await?;
let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs)); let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
output.extend(virtual_songs.map(CollectionFile::Song)); output.extend(virtual_songs.map(CollectionFile::Song));
} }
@ -66,76 +68,88 @@ impl Index {
Ok(output) 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 where
P: AsRef<Path>, P: AsRef<Path>,
{ {
use self::songs::dsl::*; let vfs = self.vfs_manager.get_vfs().await?;
let vfs = self.vfs_manager.get_vfs()?; let mut connection = self.db.connect().await?;
let mut connection = self.db.connect()?;
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 real_path = vfs.virtual_to_real(virtual_path)?;
let song_path_filter = { let song_path_filter = {
let mut path_buf = real_path; let mut path_buf = real_path;
path_buf.push("%"); path_buf.push("%");
path_buf.as_path().to_string_lossy().into_owned() path_buf.as_path().to_string_lossy().into_owned()
}; };
songs sqlx::query_as!(
.filter(path.like(&song_path_filter)) Song,
.order(path) "SELECT * FROM songs WHERE path LIKE $1 ORDER BY path COLLATE NOCASE ASC",
.load(&mut connection)? song_path_filter
)
.fetch_all(connection.as_mut())
.await?
} else { } 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)); let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
Ok(virtual_songs.collect::<Vec<_>>()) Ok(virtual_songs.collect::<Vec<_>>())
} }
pub fn get_random_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> { pub async fn get_random_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
use self::directories::dsl::*; let vfs = self.vfs_manager.get_vfs().await?;
let vfs = self.vfs_manager.get_vfs()?; let mut connection = self.db.connect().await?;
let mut connection = self.db.connect()?;
let real_directories: Vec<Directory> = directories let real_directories = sqlx::query_as!(
.filter(album.is_not_null()) Directory,
.limit(count) "SELECT * FROM directories WHERE album IS NOT NULL ORDER BY RANDOM() DESC LIMIT $1",
.order(random()) count
.load(&mut connection)?; )
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories let virtual_directories = real_directories
.into_iter() .into_iter()
.filter_map(|d| d.virtualize(&vfs)); .filter_map(|d| d.virtualize(&vfs));
Ok(virtual_directories.collect::<Vec<_>>()) Ok(virtual_directories.collect::<Vec<_>>())
} }
pub fn get_recent_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> { pub async fn get_recent_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
use self::directories::dsl::*; let vfs = self.vfs_manager.get_vfs().await?;
let vfs = self.vfs_manager.get_vfs()?; let mut connection = self.db.connect().await?;
let mut connection = self.db.connect()?;
let real_directories: Vec<Directory> = directories let real_directories = sqlx::query_as!(
.filter(album.is_not_null()) Directory,
.order(date_added.desc()) "SELECT * FROM directories WHERE album IS NOT NULL ORDER BY date_added DESC LIMIT $1",
.limit(count) count
.load(&mut connection)?; )
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories let virtual_directories = real_directories
.into_iter() .into_iter()
.filter_map(|d| d.virtualize(&vfs)); .filter_map(|d| d.virtualize(&vfs));
Ok(virtual_directories.collect::<Vec<_>>()) Ok(virtual_directories.collect::<Vec<_>>())
} }
pub fn search(&self, query: &str) -> Result<Vec<CollectionFile>, QueryError> { pub async fn search(&self, query: &str) -> Result<Vec<CollectionFile>, QueryError> {
let vfs = self.vfs_manager.get_vfs()?; let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect()?; let mut connection = self.db.connect().await?;
let like_test = format!("%{}%", query); let like_test = format!("%{}%", query);
let mut output = Vec::new(); let mut output = Vec::new();
// Find dirs with matching path and parent not matching // Find dirs with matching path and parent not matching
{ {
use self::directories::dsl::*; let real_directories = sqlx::query_as!(
let real_directories: Vec<Directory> = directories Directory,
.filter(path.like(&like_test)) "SELECT * FROM directories WHERE path LIKE $1 AND parent NOT LIKE $1",
.filter(parent.not_like(&like_test)) like_test
.load(&mut connection)?; )
.fetch_all(connection.as_mut())
.await?;
let virtual_directories = real_directories let virtual_directories = real_directories
.into_iter() .into_iter()
@ -146,17 +160,22 @@ impl Index {
// Find songs with matching title/album/artist and non-matching parent // Find songs with matching title/album/artist and non-matching parent
{ {
use self::songs::dsl::*; let real_songs = sqlx::query_as!(
let real_songs: Vec<Song> = songs Song,
.filter( r#"
path.like(&like_test) SELECT * FROM songs
.or(title.like(&like_test)) WHERE ( path LIKE $1
.or(album.like(&like_test)) OR title LIKE $1
.or(artist.like(&like_test)) OR album LIKE $1
.or(album_artist.like(&like_test)), OR artist LIKE $1
) OR album_artist LIKE $1
.filter(parent.not_like(&like_test)) )
.load(&mut connection)?; 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)); let virtual_songs = real_songs.into_iter().filter_map(|d| d.virtualize(&vfs));
@ -166,17 +185,20 @@ impl Index {
Ok(output) Ok(output)
} }
pub fn get_song(&self, virtual_path: &Path) -> Result<Song, QueryError> { pub async fn get_song(&self, virtual_path: &Path) -> Result<Song, QueryError> {
let vfs = self.vfs_manager.get_vfs()?; let vfs = self.vfs_manager.get_vfs().await?;
let mut connection = self.db.connect()?; let mut connection = self.db.connect().await?;
let real_path = vfs.virtual_to_real(virtual_path)?; let real_path = vfs.virtual_to_real(virtual_path)?;
let real_path_string = real_path.as_path().to_string_lossy(); let real_path_string = real_path.as_path().to_string_lossy();
use self::songs::dsl::*; let real_song = sqlx::query_as!(
let real_song: Song = songs Song,
.filter(path.eq(real_path_string)) "SELECT * FROM songs WHERE path = $1",
.get_result(&mut connection)?; real_path_string
)
.fetch_one(connection.as_mut())
.await?;
match real_song.virtualize(&vfs) { match real_song.virtualize(&vfs) {
Some(s) => Ok(s), Some(s) => Ok(s),

View file

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

View file

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

View file

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

View file

@ -1,16 +1,16 @@
use diesel::prelude::*;
use rayon::prelude::*; use rayon::prelude::*;
use sqlx::{QueryBuilder, Sqlite};
use std::path::Path; use std::path::Path;
use crate::app::vfs; 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 const INDEX_BUILDING_CLEAN_BUFFER_SIZE: usize = 500; // Deletions in each transaction
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
#[error(transparent)] #[error(transparent)]
Database(#[from] diesel::result::Error), Database(#[from] sqlx::Error),
#[error(transparent)] #[error(transparent)]
DatabaseConnection(#[from] db::Error), DatabaseConnection(#[from] db::Error),
#[error(transparent)] #[error(transparent)]
@ -29,19 +29,23 @@ impl Cleaner {
Self { db, vfs_manager } Self { db, vfs_manager }
} }
pub fn clean(&self) -> Result<(), Error> { pub async fn clean(&self) -> Result<(), Error> {
let vfs = self.vfs_manager.get_vfs()?; let vfs = self.vfs_manager.get_vfs().await?;
let all_directories: Vec<String> = { let (all_directories, all_songs) = {
let mut connection = self.db.connect()?; let mut connection = self.db.connect().await?;
directories::table
.select(directories::path)
.load(&mut connection)?
};
let all_songs: Vec<String> = { let directories = sqlx::query_scalar!("SELECT path FROM directories")
let mut connection = self.db.connect()?; .fetch_all(connection.as_mut())
songs::table.select(songs::path).load(&mut connection)? .await
.unwrap();
let songs = sqlx::query_scalar!("SELECT path FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
(directories, songs)
}; };
let list_missing_directories = || { let list_missing_directories = || {
@ -69,14 +73,26 @@ impl Cleaner {
thread_pool.join(list_missing_directories, list_missing_songs); 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) { for chunk in missing_directories[..].chunks(INDEX_BUILDING_CLEAN_BUFFER_SIZE) {
diesel::delete(directories::table.filter(directories::path.eq_any(chunk))) QueryBuilder::<Sqlite>::new("DELETE FROM directories WHERE path IN ")
.execute(&mut connection)?; .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) { for chunk in missing_songs[..].chunks(INDEX_BUILDING_CLEAN_BUFFER_SIZE) {
diesel::delete(songs::table.filter(songs::path.eq_any(chunk))) QueryBuilder::<Sqlite>::new("DELETE FROM songs WHERE path IN ")
.execute(&mut connection)?; .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 log::error;
use regex::Regex; use regex::Regex;
use super::*; use super::*;
pub struct Collector { pub struct Collector {
receiver: Receiver<traverser::Directory>, receiver: crossbeam_channel::Receiver<traverser::Directory>,
sender: Sender<inserter::Item>, sender: tokio::sync::mpsc::UnboundedSender<inserter::Item>,
album_art_pattern: Option<Regex>, album_art_pattern: Option<Regex>,
} }
impl Collector { impl Collector {
pub fn new( pub fn new(
receiver: Receiver<traverser::Directory>, receiver: crossbeam_channel::Receiver<traverser::Directory>,
sender: Sender<inserter::Item>, sender: tokio::sync::mpsc::UnboundedSender<inserter::Item>,
album_art_pattern: Option<Regex>, album_art_pattern: Option<Regex>,
) -> Self { ) -> Self {
Self { Self {

View file

@ -1,13 +1,11 @@
use crossbeam_channel::Receiver;
use diesel::prelude::*;
use log::error; 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 const INDEX_BUILDING_INSERT_BUFFER_SIZE: usize = 1000; // Insertions in each transaction
#[derive(Debug, Insertable)]
#[diesel(table_name = songs)]
pub struct Song { pub struct Song {
pub path: String, pub path: String,
pub parent: String, pub parent: String,
@ -26,8 +24,6 @@ pub struct Song {
pub label: Option<String>, pub label: Option<String>,
} }
#[derive(Debug, Insertable)]
#[diesel(table_name = directories)]
pub struct Directory { pub struct Directory {
pub path: String, pub path: String,
pub parent: Option<String>, pub parent: Option<String>,
@ -44,14 +40,14 @@ pub enum Item {
} }
pub struct Inserter { pub struct Inserter {
receiver: Receiver<Item>, receiver: UnboundedReceiver<Item>,
new_directories: Vec<Directory>, new_directories: Vec<Directory>,
new_songs: Vec<Song>, new_songs: Vec<Song>,
db: DB, db: DB,
} }
impl Inserter { 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_directories = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE);
let new_songs = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE); let new_songs = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE);
Self { Self {
@ -62,63 +58,90 @@ impl Inserter {
} }
} }
pub fn insert(&mut self) { pub async fn insert(&mut self) {
while let Ok(item) = self.receiver.recv() { while let Some(item) = self.receiver.recv().await {
self.insert_item(item); 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 { match insert {
Item::Directory(d) => { Item::Directory(d) => {
self.new_directories.push(d); self.new_directories.push(d);
if self.new_directories.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE { if self.new_directories.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE {
self.flush_directories(); self.flush_directories().await;
} }
} }
Item::Song(s) => { Item::Song(s) => {
self.new_songs.push(s); self.new_songs.push(s);
if self.new_songs.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE { if self.new_songs.len() >= INDEX_BUILDING_INSERT_BUFFER_SIZE {
self.flush_songs(); self.flush_songs().await;
} }
} }
}; };
} }
fn flush_directories(&mut self) { async fn flush_directories(&mut self) {
let res = self.db.connect().ok().and_then(|mut connection| { let Ok(mut connection) = self.db.connect().await else {
diesel::insert_into(directories::table) error!("Could not acquire connection to insert new directories in database");
.values(&self.new_directories) return;
.execute(&mut *connection) // TODO https://github.com/diesel-rs/diesel/issues/1822 };
.ok()
}); let result = QueryBuilder::<Sqlite>::new(
if res.is_none() { "INSERT INTO directories(path, parent, artist, year, album, artwork, date_added) ",
error!("Could not insert new directories in database"); )
} .push_values(&self.new_directories, |mut b, directory| {
self.new_directories.clear(); 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) { async fn flush_songs(&mut self) {
let res = self.db.connect().ok().and_then(|mut connection| { let Ok(mut connection) = self.db.connect().await else {
diesel::insert_into(songs::table) error!("Could not acquire connection to insert new songs in database");
.values(&self.new_songs) return;
.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();
}
}
impl Drop for Inserter { 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) ")
fn drop(&mut self) { .push_values(&self.new_songs, |mut b, song| {
if !self.new_directories.is_empty() { b.push_bind(&song.path)
self.flush_directories(); .push_bind(&song.parent)
} .push_bind(song.track_number)
if !self.new_songs.is_empty() { .push_bind(song.disc_number)
self.flush_songs(); .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()) .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 mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let auth_response = scrobbler let auth_response = scrobbler
.authenticate_with_token(lastfm_token) .authenticate_with_token(lastfm_token)
@ -52,28 +52,30 @@ impl Manager {
self.user_manager self.user_manager
.lastfm_link(username, &auth_response.name, &auth_response.key) .lastfm_link(username, &auth_response.name, &auth_response.key)
.await
.map_err(|e| e.into()) .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 self.user_manager
.lastfm_unlink(username) .lastfm_unlink(username)
.await
.map_err(|e| e.into()) .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 mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let scrobble = self.scrobble_from_path(track)?; let scrobble = self.scrobble_from_path(track).await?;
let auth_token = self.user_manager.get_lastfm_session_key(username)?; let auth_token = self.user_manager.get_lastfm_session_key(username).await?;
scrobbler.authenticate_with_session_key(&auth_token); scrobbler.authenticate_with_session_key(&auth_token);
scrobbler.scrobble(&scrobble).map_err(Error::Scrobble)?; scrobbler.scrobble(&scrobble).map_err(Error::Scrobble)?;
Ok(()) 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 mut scrobbler = Scrobbler::new(LASTFM_API_KEY, LASTFM_API_SECRET);
let scrobble = self.scrobble_from_path(track)?; let scrobble = self.scrobble_from_path(track).await?;
let auth_token = self.user_manager.get_lastfm_session_key(username)?; let auth_token = self.user_manager.get_lastfm_session_key(username).await?;
scrobbler.authenticate_with_session_key(&auth_token); scrobbler.authenticate_with_session_key(&auth_token);
scrobbler scrobbler
.now_playing(&scrobble) .now_playing(&scrobble)
@ -81,8 +83,8 @@ impl Manager {
Ok(()) Ok(())
} }
fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble, Error> { async fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble, Error> {
let song = self.index.get_song(track)?; let song = self.index.get_song(track).await?;
Ok(Scrobble::new( Ok(Scrobble::new(
song.artist.as_deref().unwrap_or(""), song.artist.as_deref().unwrap_or(""),
song.title.as_deref().unwrap_or(""), song.title.as_deref().unwrap_or(""),

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
use core::ops::Deref; use core::ops::Deref;
use diesel::prelude::*;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Acquire, QueryBuilder, Sqlite};
use std::path::{self, Path, PathBuf}; use std::path::{self, Path, PathBuf};
use crate::db::{self, mount_points, DB}; use crate::db::{self, DB};
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
@ -15,11 +15,10 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
DatabaseConnection(#[from] db::Error), DatabaseConnection(#[from] db::Error),
#[error(transparent)] #[error(transparent)]
Database(#[from] diesel::result::Error), Database(#[from] sqlx::Error),
} }
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Eq, Queryable, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[diesel(table_name = mount_points)]
pub struct MountDir { pub struct MountDir {
pub source: String, pub source: String,
pub name: String, pub name: String,
@ -98,31 +97,39 @@ impl Manager {
Self { db } Self { db }
} }
pub fn get_vfs(&self) -> Result<VFS, Error> { pub async fn get_vfs(&self) -> Result<VFS, Error> {
let mount_dirs = self.mount_dirs()?; let mount_dirs = self.mount_dirs().await?;
let mounts = mount_dirs.into_iter().map(|p| p.into()).collect(); let mounts = mount_dirs.into_iter().map(|p| p.into()).collect();
Ok(VFS::new(mounts)) Ok(VFS::new(mounts))
} }
pub fn mount_dirs(&self) -> Result<Vec<MountDir>, Error> { pub async fn mount_dirs(&self) -> Result<Vec<MountDir>, Error> {
use self::mount_points::dsl::*; Ok(
let mut connection = self.db.connect()?; sqlx::query_as!(MountDir, "SELECT source, name FROM mount_points")
let mount_dirs: Vec<MountDir> = mount_points .fetch_all(self.db.connect().await?.as_mut())
.select((source, name)) .await?,
.get_results(&mut connection)?; )
Ok(mount_dirs)
} }
pub fn set_mount_dirs(&self, mount_dirs: &[MountDir]) -> Result<(), Error> { pub async fn set_mount_dirs(&self, mount_dirs: &[MountDir]) -> Result<(), Error> {
let mut connection = self.db.connect()?; let mut connection = self.db.connect().await?;
connection.transaction::<_, diesel::result::Error, _>(|connection| {
use self::mount_points::dsl::*; connection.begin().await?;
diesel::delete(mount_points).execute(&mut *connection)?;
diesel::insert_into(mount_points) sqlx::query!("DELETE FROM mount_points")
.values(mount_dirs) .execute(connection.as_mut())
.execute(&mut *connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822 .await?;
Ok(())
})?; 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(()) 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}; use std::path::{Path, PathBuf};
mod schema; use sqlx::{
migrate::Migrator,
pool::PoolConnection,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteSynchronous},
Sqlite,
};
pub use self::schema::*; static MIGRATOR: Migrator = sqlx::migrate!("src/db");
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
@ -25,74 +23,46 @@ pub enum Error {
#[derive(Clone)] #[derive(Clone)]
pub struct DB { pub struct DB {
pool: r2d2::Pool<ConnectionManager<SqliteConnection>>, pool: SqlitePool,
}
#[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(())
}
} }
impl DB { impl DB {
pub fn new(path: &Path) -> Result<DB, Error> { pub async fn new(path: &Path) -> Result<DB, Error> {
let directory = path.parent().unwrap(); let directory = path.parent().unwrap();
std::fs::create_dir_all(directory).map_err(|e| Error::Io(directory.to_owned(), e))?; 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() let pool = SqlitePool::connect_lazy_with(
.connection_customizer(Box::new(ConnectionCustomizer {})) SqliteConnectOptions::new()
.build(manager) .create_if_missing(true)
.or(Err(Error::ConnectionPoolBuild))?; .filename(path)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal),
);
let db = DB { pool }; let db = DB { pool };
db.migrate_up()?; db.migrate_up().await?;
Ok(db) Ok(db)
} }
pub fn connect(&self) -> Result<PooledConnection<ConnectionManager<SqliteConnection>>, Error> { pub async fn connect(&self) -> Result<PoolConnection<Sqlite>, Error> {
self.pool.get().or(Err(Error::ConnectionPool)) self.pool.acquire().await.map_err(|_| Error::ConnectionPool)
} }
#[cfg(test)] async fn migrate_up(&self) -> Result<(), Error> {
fn migrate_down(&self) -> Result<(), Error> { MIGRATOR
let mut connection = self.connect()?; .run(&self.pool)
connection .await
.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)
.and(Ok(())) .and(Ok(()))
.or(Err(Error::Migration)) .or(Err(Error::Migration))
} }
} }
#[test] #[tokio::test]
fn run_migrations() { async fn run_migrations() {
use crate::test::*; use crate::test::*;
use crate::test_name; use crate::test_name;
let output_dir = prepare_test_directory(test_name!()); let output_dir = prepare_test_directory(test_name!());
let db_path = output_dir.join("db.sqlite"); let db_path = output_dir.join("db.sqlite");
let db = DB::new(&db_path).unwrap(); let db = DB::new(&db_path).await.unwrap();
db.migrate_up().await.unwrap();
db.migrate_down().unwrap();
db.migrate_up().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")] #![cfg_attr(all(windows, feature = "ui"), windows_subsystem = "windows")]
#![recursion_limit = "256"] #![recursion_limit = "256"]
#[macro_use] use log::{error, info};
extern crate diesel; use options::CLIOptions;
#[macro_use]
extern crate diesel_migrations;
use log::info;
use simplelog::{ use simplelog::{
ColorChoice, CombinedLogger, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger, ColorChoice, CombinedLogger, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger,
}; };
@ -27,6 +23,8 @@ mod utils;
pub enum Error { pub enum Error {
#[error(transparent)] #[error(transparent)]
App(#[from] app::Error), App(#[from] app::Error),
#[error("Could not start web services")]
ServiceStartup(std::io::Error),
#[error("Could not parse command line arguments:\n\n{0}")] #[error("Could not parse command line arguments:\n\n{0}")]
CliArgsParsing(getopts::Fail), CliArgsParsing(getopts::Fail),
#[cfg(unix)] #[cfg(unix)]
@ -139,16 +137,21 @@ fn main() -> Result<(), Error> {
info!("Swagger files location is {:#?}", paths.swagger_dir_path); info!("Swagger files location is {:#?}", paths.swagger_dir_path);
info!("Web client files location is {:#?}", paths.web_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 // 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.index.begin_periodic_updates();
app.ddns_manager.begin_periodic_updates(); app.ddns_manager.begin_periodic_updates();
// Start server // Start server
info!("Starting up server"); info!("Starting up server");
std::thread::spawn(move || { if let Err(e) = service::launch(app) {
let _ = service::run(app); return Err(Error::ServiceStartup(e));
}); }
// Send readiness notification // Send readiness notification
#[cfg(unix)] #[cfg(unix)]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ pub enum APIError {
#[error("Could not encode Branca token")] #[error("Could not encode Branca token")]
BrancaTokenEncoding, BrancaTokenEncoding,
#[error("Database error:\n\n{0}")] #[error("Database error:\n\n{0}")]
Database(diesel::result::Error), Database(sqlx::Error),
#[error("DDNS update query failed with HTTP status {0}")] #[error("DDNS update query failed with HTTP status {0}")]
DdnsUpdateQueryFailed(u16), DdnsUpdateQueryFailed(u16),
#[error("Cannot delete your own account")] #[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 use crate::service::actix::test::ServiceType;
pub trait TestService { pub trait TestService {
fn new(test_name: &str) -> Self; async fn new(test_name: &str) -> Self;
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()>; async fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>)
fn fetch_bytes<T: Serialize + Clone + 'static>( -> Response<()>;
async fn fetch_bytes<T: Serialize + Clone + 'static>(
&mut self, &mut self,
request: &Request<T>, request: &Request<T>,
) -> Response<Vec<u8>>; ) -> Response<Vec<u8>>;
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>( async fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
&mut self, &mut self,
request: &Request<T>, request: &Request<T>,
) -> Response<U>; ) -> Response<U>;
fn complete_initial_setup(&mut self) { async fn complete_initial_setup(&mut self) {
let configuration = dto::Config { let configuration = dto::Config {
users: Some(vec![ users: Some(vec![
dto::NewUser { dto::NewUser {
@ -58,40 +59,43 @@ pub trait TestService {
..Default::default() ..Default::default()
}; };
let request = protocol::apply_config(configuration); let request = protocol::apply_config(configuration);
let response = self.fetch(&request); let response = self.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); 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 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); assert_eq!(response.status(), StatusCode::OK);
let authorization = response.into_body(); let authorization = response.into_body();
self.set_authorization(Some(authorization)); self.set_authorization(Some(authorization));
} }
fn login_admin(&mut self) { async fn login_admin(&mut self) {
self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN); self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN)
.await;
} }
fn login(&mut self) { async fn login(&mut self) {
self.login_internal(TEST_USERNAME, TEST_PASSWORD); self.login_internal(TEST_USERNAME, TEST_PASSWORD).await;
} }
fn logout(&mut self) { async fn logout(&mut self) {
self.set_authorization(None); self.set_authorization(None);
} }
fn set_authorization(&mut self, authorization: Option<dto::Authorization>); fn set_authorization(&mut self, authorization: Option<dto::Authorization>);
fn index(&mut self) { async fn index(&mut self) {
let request = protocol::trigger_index(); let request = protocol::trigger_index();
let response = self.fetch(&request); let response = self.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
loop { loop {
let browse_request = protocol::browse(Path::new("")); 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(); let entries = response.body();
if !entries.is_empty() { if !entries.is_empty() {
break; break;
@ -101,7 +105,9 @@ pub trait TestService {
loop { loop {
let flatten_request = protocol::flatten(Path::new("")); 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(); let entries = response.body();
if !entries.is_empty() { if !entries.is_empty() {
break; break;

View file

@ -5,20 +5,20 @@ use crate::service::dto;
use crate::service::test::{protocol, ServiceType, TestService}; use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn returns_api_version() { async fn returns_api_version() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::version(); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn initial_setup_golden_path() { async fn initial_setup_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::initial_setup(); 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); assert_eq!(response.status(), StatusCode::OK);
let initial_setup = response.body(); let initial_setup = response.body();
assert_eq!( 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); assert_eq!(response.status(), StatusCode::OK);
let initial_setup = response.body(); let initial_setup = response.body();
assert_eq!( assert_eq!(
@ -42,40 +42,44 @@ fn initial_setup_golden_path() {
} }
} }
#[test] #[actix_web::test]
fn trigger_index_golden_path() { async fn trigger_index_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
let request = protocol::random(); 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(); let entries = response.body();
assert_eq!(entries.len(), 0); 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(); let entries = response.body();
assert_eq!(entries.len(), 3); assert_eq!(entries.len(), 3);
} }
#[test] #[actix_web::test]
fn trigger_index_requires_auth() { async fn trigger_index_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::trigger_index(); let request = protocol::trigger_index();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn trigger_index_requires_admin() { async fn trigger_index_requires_admin() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::trigger_index(); let request = protocol::trigger_index();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN); 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::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn login_rejects_bad_username() { async fn login_rejects_bad_username() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::login("garbage", TEST_PASSWORD); let request = protocol::login("garbage", TEST_PASSWORD);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn login_rejects_bad_password() { async fn login_rejects_bad_password() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::login(TEST_USERNAME, "garbage"); let request = protocol::login(TEST_USERNAME, "garbage");
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn login_golden_path() { async fn login_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); 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); assert_eq!(response.status(), StatusCode::OK);
let authorization = response.body(); let authorization = response.body();
@ -40,73 +40,73 @@ fn login_golden_path() {
assert!(!authorization.token.is_empty()); assert!(!authorization.token.is_empty());
} }
#[test] #[actix_web::test]
fn authentication_via_bearer_http_header_rejects_bad_token() { async fn authentication_via_bearer_http_header_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let mut request = protocol::random(); let mut request = protocol::random();
let bearer = headers::Authorization::bearer("garbage").unwrap(); let bearer = headers::Authorization::bearer("garbage").unwrap();
request.headers_mut().typed_insert(bearer); request.headers_mut().typed_insert(bearer);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn authentication_via_bearer_http_header_golden_path() { async fn authentication_via_bearer_http_header_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let authorization = { let authorization = {
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); 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); assert_eq!(response.status(), StatusCode::OK);
response.into_body() response.into_body()
}; };
service.logout(); service.logout().await;
let mut request = protocol::random(); let mut request = protocol::random();
let bearer = headers::Authorization::bearer(&authorization.token).unwrap(); let bearer = headers::Authorization::bearer(&authorization.token).unwrap();
request.headers_mut().typed_insert(bearer); request.headers_mut().typed_insert(bearer);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn authentication_via_query_param_rejects_bad_token() { async fn authentication_via_query_param_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let mut request = protocol::random(); let mut request = protocol::random();
*request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token") *request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token")
.parse() .parse()
.unwrap(); .unwrap();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn authentication_via_query_param_golden_path() { async fn authentication_via_query_param_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let authorization = { let authorization = {
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); 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); assert_eq!(response.status(), StatusCode::OK);
response.into_body() response.into_body()
}; };
service.logout(); service.logout().await;
let mut request = protocol::random(); let mut request = protocol::random();
*request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token) *request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token)
.parse() .parse()
.unwrap(); .unwrap();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); 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::service::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn browse_requires_auth() { async fn browse_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::browse(&PathBuf::new()); let request = protocol::browse(&PathBuf::new());
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn browse_root() { async fn browse_root() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::browse(&PathBuf::new()); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 1); assert_eq!(entries.len(), 1);
} }
#[test] #[actix_web::test]
fn browse_directory() { async fn browse_directory() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect(); let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::browse(&path); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 5); assert_eq!(entries.len(), 5);
} }
#[test] #[actix_web::test]
fn browse_bad_directory() { async fn browse_bad_directory() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect(); let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::browse(&path); let request = protocol::browse(&path);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[test] #[actix_web::test]
fn flatten_requires_auth() { async fn flatten_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::flatten(&PathBuf::new()); let request = protocol::flatten(&PathBuf::new());
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn flatten_root() { async fn flatten_root() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::flatten(&PathBuf::new()); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 13); assert_eq!(entries.len(), 13);
} }
#[test] #[actix_web::test]
fn flatten_directory() { async fn flatten_directory() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::flatten(Path::new(TEST_MOUNT_NAME)); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 13); assert_eq!(entries.len(), 13);
} }
#[test] #[actix_web::test]
fn flatten_bad_directory() { async fn flatten_bad_directory() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect(); let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::flatten(&path); let request = protocol::flatten(&path);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[test] #[actix_web::test]
fn random_requires_auth() { async fn random_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::random(); let request = protocol::random();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn random_golden_path() { async fn random_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::random(); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 3); assert_eq!(entries.len(), 3);
} }
#[test] #[actix_web::test]
fn random_with_trailing_slash() { async fn random_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let mut request = protocol::random(); let mut request = protocol::random();
add_trailing_slash(&mut request); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 3); assert_eq!(entries.len(), 3);
} }
#[test] #[actix_web::test]
fn recent_requires_auth() { async fn recent_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::recent(); let request = protocol::recent();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn recent_golden_path() { async fn recent_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::recent(); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 3); assert_eq!(entries.len(), 3);
} }
#[test] #[actix_web::test]
fn recent_with_trailing_slash() { async fn recent_with_trailing_slash() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let mut request = protocol::recent(); let mut request = protocol::recent();
add_trailing_slash(&mut request); 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); assert_eq!(response.status(), StatusCode::OK);
let entries = response.body(); let entries = response.body();
assert_eq!(entries.len(), 3); assert_eq!(entries.len(), 3);
} }
#[test] #[actix_web::test]
fn search_requires_auth() { async fn search_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::search(""); let request = protocol::search("");
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn search_without_query() { async fn search_without_query() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::search(""); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn search_with_query() { async fn search_with_query() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let request = protocol::search("door"); 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(); let results = response.body();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
match results[0] { match results[0] {

View file

@ -4,60 +4,60 @@ use crate::service::dto;
use crate::service::test::{protocol, ServiceType, TestService}; use crate::service::test::{protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn get_ddns_config_requires_admin() { async fn get_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::get_ddns_config(); 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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login(); service.login().await;
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
#[test] #[actix_web::test]
fn get_ddns_config_golden_path() { async fn get_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
let request = protocol::get_ddns_config(); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn put_ddns_config_requires_admin() { async fn put_ddns_config_requires_admin() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::put_ddns_config(dto::DDNSConfig { let request = protocol::put_ddns_config(dto::DDNSConfig {
host: "test".to_owned(), host: "test".to_owned(),
username: "test".to_owned(), username: "test".to_owned(),
password: "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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
service.login(); service.login().await;
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
#[test] #[actix_web::test]
fn put_ddns_config_golden_path() { async fn put_ddns_config_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
let request = protocol::put_ddns_config(dto::DDNSConfig { let request = protocol::put_ddns_config(dto::DDNSConfig {
host: "test".to_owned(), host: "test".to_owned(),
username: "test".to_owned(), username: "test".to_owned(),
password: "test".to_owned(), password: "test".to_owned(),
}); });
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); 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::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn lastfm_scrobble_ignores_unlinked_user() { async fn lastfm_scrobble_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
.collect(); .collect();
let request = protocol::lastfm_scrobble(&path); let request = protocol::lastfm_scrobble(&path);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
} }
#[test] #[actix_web::test]
fn lastfm_now_playing_ignores_unlinked_user() { async fn lastfm_now_playing_ignores_unlinked_user() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
.collect(); .collect();
let request = protocol::lastfm_now_playing(&path); 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); assert_eq!(response.status(), StatusCode::NO_CONTENT);
} }
#[test] #[actix_web::test]
fn lastfm_link_token_requires_auth() { async fn lastfm_link_token_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::lastfm_link_token(); let request = protocol::lastfm_link_token();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn lastfm_link_token_golden_path() { async fn lastfm_link_token_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::lastfm_link_token(); 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); assert_eq!(response.status(), StatusCode::OK);
let link_token = response.body(); let link_token = response.body();
assert!(!link_token.value.is_empty()); 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::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn audio_requires_auth() { async fn audio_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
.collect(); .collect();
let request = protocol::audio(&path); let request = protocol::audio(&path);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn audio_golden_path() { async fn audio_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
.collect(); .collect();
let request = protocol::audio(&path); 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.status(), StatusCode::OK);
assert_eq!(response.body().len(), 24_142); assert_eq!(response.body().len(), 24_142);
assert_eq!( assert_eq!(
@ -40,13 +40,13 @@ fn audio_golden_path() {
); );
} }
#[test] #[actix_web::test]
fn audio_does_not_encode_content() { async fn audio_does_not_encode_content() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
@ -59,7 +59,7 @@ fn audio_does_not_encode_content() {
HeaderValue::from_str("gzip, deflate, br").unwrap(), 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.status(), StatusCode::OK);
assert_eq!(response.body().len(), 24_142); assert_eq!(response.body().len(), 24_142);
assert_eq!(response.headers().get(header::TRANSFER_ENCODING), None); assert_eq!(response.headers().get(header::TRANSFER_ENCODING), None);
@ -69,13 +69,13 @@ fn audio_does_not_encode_content() {
); );
} }
#[test] #[actix_web::test]
fn audio_partial_content() { async fn audio_partial_content() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
.iter() .iter()
@ -88,7 +88,7 @@ fn audio_partial_content() {
HeaderValue::from_str("bytes=100-299").unwrap(), 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.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.body().len(), 200); assert_eq!(response.body().len(), 200);
assert_eq!( assert_eq!(
@ -97,22 +97,22 @@ fn audio_partial_content() {
); );
} }
#[test] #[actix_web::test]
fn audio_bad_path_returns_not_found() { async fn audio_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect(); let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::audio(&path); let request = protocol::audio(&path);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[test] #[actix_web::test]
fn thumbnail_requires_auth() { async fn thumbnail_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"]
.iter() .iter()
@ -121,17 +121,17 @@ fn thumbnail_requires_auth() {
let size = None; let size = None;
let pad = None; let pad = None;
let request = protocol::thumbnail(&path, size, pad); let request = protocol::thumbnail(&path, size, pad);
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn thumbnail_golden_path() { async fn thumbnail_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"] let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"]
.iter() .iter()
@ -140,60 +140,60 @@ fn thumbnail_golden_path() {
let size = None; let size = None;
let pad = None; let pad = None;
let request = protocol::thumbnail(&path, size, pad); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn thumbnail_bad_path_returns_not_found() { async fn thumbnail_bad_path_returns_not_found() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect(); let path: PathBuf = ["not_my_collection"].iter().collect();
let size = None; let size = None;
let pad = None; let pad = None;
let request = protocol::thumbnail(&path, size, pad); 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); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[test] #[actix_web::test]
fn thumbnail_size_default() { async fn thumbnail_size_default() {
thumbnail_size(&test_name!(), None, None, 400); thumbnail_size(&test_name!(), None, None, 400).await;
} }
#[test] #[actix_web::test]
fn thumbnail_size_small() { async fn thumbnail_size_small() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Small), None, 400); thumbnail_size(&test_name!(), Some(ThumbnailSize::Small), None, 400).await;
} }
#[test] #[actix_web::test]
#[cfg(not(tarpaulin))] #[cfg(not(tarpaulin))]
fn thumbnail_size_large() { async fn thumbnail_size_large() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Large), None, 1200); thumbnail_size(&test_name!(), Some(ThumbnailSize::Large), None, 1200).await;
} }
#[test] #[actix_web::test]
#[cfg(not(tarpaulin))] #[cfg(not(tarpaulin))]
fn thumbnail_size_native() { async fn thumbnail_size_native() {
thumbnail_size(&test_name!(), Some(ThumbnailSize::Native), None, 1423); thumbnail_size(&test_name!(), Some(ThumbnailSize::Native), None, 1423).await;
} }
fn thumbnail_size(name: &str, size: Option<ThumbnailSize>, pad: Option<bool>, expected: u32) { async fn thumbnail_size(name: &str, size: Option<ThumbnailSize>, pad: Option<bool>, expected: u32) {
let mut service = ServiceType::new(name); let mut service = ServiceType::new(name).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
service.index(); service.index().await;
service.login(); service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic", "Folder.png"] let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic", "Folder.png"]
.iter() .iter()
.collect(); .collect();
let request = protocol::thumbnail(&path, size, pad); 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); assert_eq!(response.status(), StatusCode::OK);
let thumbnail = image::load_from_memory(response.body()).unwrap().to_rgb8(); let thumbnail = image::load_from_memory(response.body()).unwrap().to_rgb8();
assert_eq!(thumbnail.width(), expected); 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::service::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn list_playlists_requires_auth() { async fn list_playlists_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::playlists(); let request = protocol::playlists();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn list_playlists_golden_path() { async fn list_playlists_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::playlists(); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn save_playlist_requires_auth() { async fn save_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); 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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn save_playlist_golden_path() { async fn save_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn save_playlist_large() { async fn save_playlist_large() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let tracks = (0..100_000) let tracks = (0..100_000)
.map(|_| "My Super Cool Song".to_string()) .map(|_| "My Super Cool Song".to_string())
.collect(); .collect();
let my_playlist = dto::SavePlaylistInput { tracks }; let my_playlist = dto::SavePlaylistInput { tracks };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn get_playlist_requires_auth() { async fn get_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME); 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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn get_playlist_golden_path() { async fn get_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
{ {
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); 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); assert_eq!(response.status(), StatusCode::OK);
} }
let request = protocol::read_playlist(TEST_PLAYLIST_NAME); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn get_playlist_bad_name_returns_not_found() { async fn get_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME); 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); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[test] #[actix_web::test]
fn delete_playlist_requires_auth() { async fn delete_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME); 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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn delete_playlist_golden_path() { async fn delete_playlist_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
{ {
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); 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); assert_eq!(response.status(), StatusCode::OK);
} }
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn delete_playlist_bad_name_returns_not_found() { async fn delete_playlist_bad_name_returns_not_found() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME); 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); 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::service::test::{protocol, ServiceType, TestService};
use crate::test_name; use crate::test_name;
#[test] #[actix_web::test]
fn get_settings_requires_auth() { async fn get_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::get_settings(); let request = protocol::get_settings();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn get_settings_requires_admin() { async fn get_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::get_settings(); let request = protocol::get_settings();
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
#[test] #[actix_web::test]
fn get_settings_golden_path() { async fn get_settings_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
let request = protocol::get_settings(); 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); assert_eq!(response.status(), StatusCode::OK);
} }
#[test] #[actix_web::test]
fn put_settings_requires_auth() { async fn put_settings_requires_auth() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
let request = protocol::put_settings(dto::NewSettings::default()); 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); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[test] #[actix_web::test]
fn put_settings_requires_admin() { async fn put_settings_requires_admin() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login(); service.login().await;
let request = protocol::put_settings(dto::NewSettings::default()); 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); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
#[test] #[actix_web::test]
fn put_settings_golden_path() { async fn put_settings_golden_path() {
let mut service = ServiceType::new(&test_name!()); let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup(); service.complete_initial_setup().await;
service.login_admin(); service.login_admin().await;
let request = protocol::put_settings(dto::NewSettings { let request = protocol::put_settings(dto::NewSettings {
album_art_pattern: Some("test_pattern".to_owned()), album_art_pattern: Some("test_pattern".to_owned()),
reindex_every_n_seconds: Some(31), reindex_every_n_seconds: Some(31),
}); });
let response = service.fetch(&request); let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let request = protocol::get_settings(); 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(); let settings = response.body();
assert_eq!( assert_eq!(
settings, settings,

View file

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

View file

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