diff --git a/rave/src/scan.rs b/rave/src/scan.rs index 5e11875..cdd72b6 100644 --- a/rave/src/scan.rs +++ b/rave/src/scan.rs @@ -184,8 +184,6 @@ async fn handle_entry( debug!("Inserted track {:?}", track.id); - // TODO: figure out how to scan. steal from Gonic if we have to :3 - Ok(()) } @@ -194,6 +192,8 @@ mod mp3; #[instrument(skip(tx))] async fn find_or_create_genre(tx: &DatabaseTransaction, name: &str) -> Result { + let name = name.replace('\0', ""); // remove null bytes. they're not allowed :3 + let name = name.trim(); debug!("Finding genre with name {name}"); let res = Genre::find() .filter(genre::Column::Name.eq(name)) @@ -448,3 +448,7 @@ const fn mime_type_to_ext(mime: MimeType) -> &'static str { MimeType::Gif => "gif", } } + +pub async fn clear_errors() { + STATUS.write().await.errors.clear(); +} diff --git a/rave/src/scan/flac.rs b/rave/src/scan/flac.rs index a0f508d..6f5d787 100644 --- a/rave/src/scan/flac.rs +++ b/rave/src/scan/flac.rs @@ -1,6 +1,6 @@ -use std::borrow::Cow; use std::path::PathBuf; use std::sync::Arc; +use std::{borrow::Cow, result::Result}; use audiotags::{AudioTagEdit, FlacTag, MimeType, Tag}; use color_eyre::Report; @@ -28,7 +28,9 @@ pub async fn handle( let meta = { File::open(&path).await?.metadata().await? }; let tag = { - let tag = Tag::default().read_from_path(&path)?; + let tag = Tag::default() + .read_from_path(&path) + .map_err(|e| Report::msg("check ID3 tags for invalid encodings").wrap_err(e))?; FlacTag::from(tag) }; @@ -37,6 +39,15 @@ pub async fn handle( let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?; + if let Some(track) = Track::find() + .filter(track::Column::Path.eq(path.to_string_lossy())) + .one(tx) + .await + .map_err(|v| Report::msg("error searching for track").wrap_err(v))? + { + return Ok(track); // early exit if we already have this track. need to do an update check though :/ + } + let mut am = track::ActiveModel::new(); am.title = Set(tag.title().unwrap_or(&stem).to_string()); @@ -81,7 +92,9 @@ pub async fn handle( }; if let Some(data) = data { - let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type).await?; + let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type) + .await + .map_err(|v| Report::msg("error getting cover art").wrap_err(v))?; am.cover_art_id = Set(Some(cover_art.id)); } } @@ -91,7 +104,10 @@ pub async fn handle( .try_into() .expect("Failed to convert meta len to i64")); - let model = Track::insert(am).exec_with_returning(tx).await?; + let model = Track::insert(am) + .exec_with_returning(tx) + .await + .map_err(|v| Report::msg("couldn't add track").wrap_err(v))?; Ok(model) } @@ -103,9 +119,11 @@ async fn find_album( tag: &FlacTag, state: Arc>, ) -> Result { - let Some(album) = tag.album() else { - return Err(Report::msg("Couldn't get album name from tag")); - }; + let album = tag.album().unwrap_or(audiotags::types::Album { + title: "Unknown Album", + artist: None, + cover: None, + }); // if not, search by name let search = Album::find() @@ -128,8 +146,23 @@ async fn find_album( let genre = tag.genre(); if let Some(genre) = genre { - let genre_id = super::find_or_create_genre(tx, genre).await?; - am.genre_ids = Set(Some(vec![genre_id])); + if genre.contains('\0') { + let genre_ids = genre + .split('\0') + .map(|genre| super::find_or_create_genre(tx, genre)) + .collect::>(); + let genre_ids = futures::future::join_all(genre_ids).await; + + let genre_ids = genre_ids + .into_iter() + .filter_map(Result::ok) + .collect::>(); + + am.genre_ids = Set(Some(genre_ids)); + } else { + let genre_id = super::find_or_create_genre(tx, genre).await?; + am.genre_ids = Set(Some(vec![genre_id])); + } } am.song_count = Set(tag .total_tracks() diff --git a/rave/src/scan/mp3.rs b/rave/src/scan/mp3.rs index c47d6f2..adf7d93 100644 --- a/rave/src/scan/mp3.rs +++ b/rave/src/scan/mp3.rs @@ -28,7 +28,9 @@ pub async fn handle( let meta = { File::open(&path).await?.metadata().await? }; let tag = { - let tag = Tag::default().read_from_path(&path)?; + let tag = Tag::default() + .read_from_path(&path) + .map_err(|e| Report::msg("check ID3 tags for invalid encodings").wrap_err(e))?; audiotags::Id3v2Tag::from(tag) }; @@ -37,6 +39,15 @@ pub async fn handle( let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?; + if let Some(track) = Track::find() + .filter(track::Column::Path.eq(path.to_string_lossy())) + .one(tx) + .await + .map_err(|v| Report::msg("error searching for track").wrap_err(v))? + { + return Ok(track); // early exit if we already have this track. need to do an update check though :/ + } + let mut am = track::ActiveModel::new(); am.title = Set(tag.title().unwrap_or(&stem).to_string()); @@ -81,7 +92,9 @@ pub async fn handle( }; if let Some(data) = data { - let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type).await?; + let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type) + .await + .map_err(|v| Report::msg("error getting cover art").wrap_err(v))?; am.cover_art_id = Set(Some(cover_art.id)); } } @@ -91,7 +104,10 @@ pub async fn handle( .try_into() .expect("Failed to convert meta len to i64")); - let model = Track::insert(am).exec_with_returning(tx).await?; + let model = Track::insert(am) + .exec_with_returning(tx) + .await + .map_err(|v| Report::msg("couldn't add track").wrap_err(v))?; Ok(model) } @@ -103,9 +119,11 @@ async fn find_album( tag: &Id3v2Tag, state: Arc>, ) -> Result { - let Some(album) = tag.album() else { - return Err(Report::msg("Couldn't get album name from tag")); - }; + let album = tag.album().unwrap_or(audiotags::types::Album { + title: "Unknown Album", + artist: None, + cover: None, + }); // if not, search by name let search = Album::find() @@ -128,8 +146,23 @@ async fn find_album( let genre = tag.genre(); if let Some(genre) = genre { - let genre_id = super::find_or_create_genre(tx, genre).await?; - am.genre_ids = Set(Some(vec![genre_id])); + if genre.contains('\0') { + let genre_ids = genre + .split('\0') + .map(|genre| super::find_or_create_genre(tx, genre)) + .collect::>(); + let genre_ids = futures::future::join_all(genre_ids).await; + + let genre_ids = genre_ids + .into_iter() + .filter_map(Result::ok) + .collect::>(); + + am.genre_ids = Set(Some(genre_ids)); + } else { + let genre_id = super::find_or_create_genre(tx, genre).await?; + am.genre_ids = Set(Some(vec![genre_id])); + } } am.song_count = Set(tag .total_tracks() diff --git a/rave/src/ui/dashboard.rs b/rave/src/ui/dashboard.rs index 86a3f2a..df972e6 100644 --- a/rave/src/ui/dashboard.rs +++ b/rave/src/ui/dashboard.rs @@ -1,20 +1,102 @@ -use crate::utils::db::DbTxn; +use crate::{ + scan::{get_scan_status, ScanStatus}, + utils::db::DbTxn, +}; use entities::{ prelude::{User, UserSession}, user::Model as UserModel, + user_session, }; use nate::Nate; use poem::{ http::{header, StatusCode}, session::Session, web::Data, - Response, + Endpoint, EndpointExt, Response, Route, }; use sea_orm::{EntityTrait, ModelTrait}; use tracing::error; +mod create_user; +mod delete_user; +mod scan; + +pub fn build() -> Box<(dyn Endpoint + 'static)> { + Route::new() + .at("/", dashboard) + .at("/create_user", create_user::create_user) + .at("/delete_user", delete_user::delete_user) + .at("/scan", scan::scan) + .boxed() +} + #[poem::handler] pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { + let (_, user) = match get_session(txn, cookie).await { + Ok((session, user)) => (session, user), + Err(response) => return response, + }; + + let message = cookie.get::("message"); + cookie.remove("message"); + + if user.is_admin { + let users = User::find().all(&**txn).await; + + let users = match users { + Ok(users) => users, + Err(e) => { + error!("Failed to find users: {e}"); + + cookie.set("message", "Internal server error occurred. Sorry!"); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + }; + + let scan_status = get_scan_status().await; + + let scan = match scan_status { + Ok(scan) => scan, + Err(e) => { + error!("Failed to get scan status: {e}"); + + cookie.set("message", "Internal server error occurred. Sorry!"); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + }; + + Response::builder().status(StatusCode::OK).body( + Dashboard { + username: user.name, + message, + admin: Some(Admin { users, scan }), + } + .to_string(), + ) + } else { + Response::builder().status(StatusCode::OK).body( + Dashboard { + username: user.name, + message, + admin: None, + } + .to_string(), + ) + } +} + +async fn get_session( + txn: &DbTxn, + cookie: &Session, +) -> Result<(user_session::Model, UserModel), Response> { let session_token = cookie.get::("session_token"); if session_token.is_none() { @@ -22,10 +104,10 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { cookie.set("message", "Please log in."); - return Response::builder() + return Err(Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, "/login") - .finish(); + .finish()); } let session_token = session_token.expect("Failed to get session token"); @@ -35,8 +117,8 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { .one(&**txn) .await; - let (_, user) = match session { - Ok(Some((session, Some(user)))) => (session, user), + match session { + Ok(Some((session, Some(user)))) => Ok((session, user)), Ok(Some((session, None))) => { cookie.clear(); @@ -44,20 +126,20 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { cookie.set("message", "Invalid session. Please log in again."); - return Response::builder() + Err(Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, "/login") - .finish(); + .finish()) } Ok(None) => { cookie.clear(); cookie.set("message", "Invalid session. Please log in again."); - return Response::builder() + Err(Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, "/login") - .finish(); + .finish()) } Err(e) => { @@ -70,50 +152,11 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { "Internal server error occurred. Sorry! Please log in again.", ); - return Response::builder() + Err(Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, "/login") - .finish(); + .finish()) } - }; - - if user.is_admin { - let users = User::find().all(&**txn).await; - - let users = match users { - Ok(users) => users, - Err(e) => { - error!("Failed to find users: {e}"); - - cookie.set( - "message", - "Internal server error occurred. Sorry! Please log in again.", - ); - - return Response::builder() - .status(StatusCode::FOUND) - .header(header::LOCATION, "/dashboard") - .finish(); - } - }; - - Response::builder().status(StatusCode::OK).body( - Dashboard { - username: user.name, - message: cookie.get::("message"), - admin: Some(Admin { users }), - } - .to_string(), - ) - } else { - Response::builder().status(StatusCode::OK).body( - Dashboard { - username: user.name, - message: cookie.get::("message"), - admin: None, - } - .to_string(), - ) } } @@ -128,4 +171,5 @@ pub struct Dashboard { #[derive(Debug, Clone)] pub struct Admin { pub users: Vec, + pub scan: ScanStatus, } diff --git a/rave/src/ui/dashboard/create_user.rs b/rave/src/ui/dashboard/create_user.rs new file mode 100644 index 0000000..ce41f39 --- /dev/null +++ b/rave/src/ui/dashboard/create_user.rs @@ -0,0 +1,101 @@ +use entities::{prelude::User, user}; +use poem::{ + http::{header, StatusCode}, + session::Session, + web::{Data, Form}, + Response, +}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::Deserialize; +use tracing::{error, warn}; + +use crate::{ui::dashboard::get_session, utils::db::DbTxn}; + +#[poem::handler] +pub async fn create_user( + Data(txn): Data<&DbTxn>, + cookie: &Session, + Form(user_params): Form, +) -> Response { + let (_, user) = match get_session(txn, cookie).await { + Ok((session, user)) => (session, user), + Err(response) => return response, + }; + + if !user.is_admin { + cookie.set("message", "ERROR: You are not an admin."); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + + let existing = User::find() + .filter(user::Column::Name.eq(&user_params.username)) + .one(&**txn) + .await; + + match existing { + Ok(None) => {} + Ok(Some(u)) => { + warn!( + "Found existing user {} while trying to add a new user {}", + u.name, user_params.username + ); + cookie.set("message", "ERROR: User already exists."); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + Err(e) => { + error!("Failed to find user: {e}"); + cookie.set( + "message", + "ERROR: Internal error while checking for existing user.", + ); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + }; + + let new_user = user::ActiveModel { + name: Set(user_params.username), + password: Set(user_params.password), + is_admin: Set(false), + ..Default::default() + }; + + let new_user = User::insert(new_user).exec(&**txn).await; + + match new_user { + Ok(_) => { + cookie.set("message", "User created successfully."); + + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish() + } + Err(e) => { + error!("Failed to create user: {e}"); + cookie.set("message", "ERROR: Internal error while creating user."); + + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish() + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateUser { + pub username: String, + pub password: String, +} diff --git a/rave/src/ui/dashboard/delete_user.rs b/rave/src/ui/dashboard/delete_user.rs new file mode 100644 index 0000000..ce4f197 --- /dev/null +++ b/rave/src/ui/dashboard/delete_user.rs @@ -0,0 +1,92 @@ +use entities::{prelude::User, user}; +use poem::{ + http::{header, StatusCode}, + session::Session, + web::{Data, Query}, + Response, +}; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use serde::Deserialize; +use tracing::{error, warn}; + +use crate::{ui::dashboard::get_session, utils::db::DbTxn}; + +#[poem::handler] +pub async fn delete_user( + Data(txn): Data<&DbTxn>, + cookie: &Session, + Query(user_params): Query, +) -> Response { + let (_, user) = match get_session(txn, cookie).await { + Ok((session, user)) => (session, user), + Err(response) => return response, + }; + + if !user.is_admin { + cookie.set("message", "ERROR: You are not an admin."); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + + let existing = User::find() + .filter(user::Column::Name.eq(&user_params.username)) + .one(&**txn) + .await; + + let u = match existing { + Ok(None) => { + warn!( + "Found no user while trying to delete a user {}", + user_params.username + ); + cookie.set("message", "ERROR: User does not exist."); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + Ok(Some(u)) => u, + Err(e) => { + error!("Failed to find user: {e}"); + cookie.set( + "message", + "ERROR: Internal error while checking for existing user.", + ); + + return Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish(); + } + }; + + let res = u.delete(&**txn).await; + + match res { + Ok(_) => { + cookie.set("message", "Successfully deleted user."); + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish() + } + Err(e) => { + error!("Failed to delete user: {e}"); + cookie.set("message", "ERROR: Internal error while deleting user."); + + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish() + } + } +} + +#[derive(Debug, Deserialize)] +pub struct DeleteUser { + pub username: String, +} diff --git a/rave/src/ui/dashboard/scan.rs b/rave/src/ui/dashboard/scan.rs new file mode 100644 index 0000000..ab19d35 --- /dev/null +++ b/rave/src/ui/dashboard/scan.rs @@ -0,0 +1,45 @@ +use poem::{ + http::{header, StatusCode, Uri}, + session::Session, + web::Data, + Response, +}; + +use crate::{ui::dashboard::get_session, utils::db::DbTxn}; + +#[poem::handler] +pub async fn scan(Data(txn): Data<&DbTxn>, cookie: &Session, uri: &Uri) -> Response { + let (_, user) = match get_session(txn, cookie).await { + Ok((session, user)) => (session, user), + Err(response) => return response, + }; + + if user.is_admin { + if let Some(query) = uri.query() { + match query { + "start" => { + crate::scan::start_scan().await; + cookie.set("message", "Scan started!"); + } + + "clearErrors" => { + crate::scan::clear_errors().await; + cookie.set("message", "Errors cleared!"); + } + + _ => { + cookie.set("message", format!("Unknown command: {query}")); + } + } + } else { + cookie.set("message", "No command provided"); + } + } else { + cookie.set("message", "You are not an admin!"); + } + + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/dashboard") + .finish() +} diff --git a/rave/src/ui/login.rs b/rave/src/ui/login.rs index c5be5ad..2b31e09 100644 --- a/rave/src/ui/login.rs +++ b/rave/src/ui/login.rs @@ -18,6 +18,8 @@ use tracing::{debug, error}; #[poem::handler] pub async fn login_ui(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { let message = cookie.get::("message"); + cookie.remove("message"); + let Some(token) = cookie.get::("session_token") else { return Response::builder() .status(StatusCode::OK) diff --git a/rave/src/ui/mod.rs b/rave/src/ui/mod.rs index d66d34f..bea63bf 100644 --- a/rave/src/ui/mod.rs +++ b/rave/src/ui/mod.rs @@ -23,7 +23,7 @@ pub fn build() -> Box> { .at("/", index::index) .at("/login", get(login::login_ui).post(login::login)) .at("/logout", get(logout::logout)) - .at("/dashboard", dashboard::dashboard) + .nest("/dashboard", dashboard::build()) .nest("/css", StaticFilesEndpoint::new(path)) .with(CookieSession::new( CookieConfig::new().name(RAVE_COOKIE_NAME), diff --git a/rave/templates/dashboard.html b/rave/templates/dashboard.html index 3b26bea..58aa733 100644 --- a/rave/templates/dashboard.html +++ b/rave/templates/dashboard.html @@ -5,6 +5,10 @@ Rave | Dashboard + + + + @@ -21,15 +25,58 @@

Admin Stuff

This is where admin-facing stuff is. I do know.

-
-

Users:

+
+

Users:

    {%- for user in &admin.users {-%} -
  • -

    {{ user.name }} - {{ user.is_admin }}

    +
  • + {{ user.name }} - {{ if user.is_admin { "Admin" } else { "User" } }} + {%- if + user.is_admin{-%} + Delete + {%-} else {-%} + Delete + {%-}-%}
  • {%- } -%}
+

Add a user:

+
+
+ + + +
+
+
+
+
+

Scan for new files:

+
Scan + + Refresh + + Clear Errors +
+
+

Scanning: {{ admin.scan.scanning }}

+

Scanned: {{ admin.scan.count }}

+ + {%- if !admin.scan.errors.is_empty() {-%} +

Errors:

+
    + {%- for error in &admin.scan.errors {-%} +
  • + {{ error.additional }}: + {%- for cause in error.report.chain() { -%} + {{ cause }} — + {%- } -%} +
  • + {%- } -%} +
+ {%-}-%} +
{%- } -%} diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 4a78bca..b75bf90 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -1,11 +1,13 @@ body { - background-color: #0a0a0a; + background-color: #070707; color: #f0f0f0; display: flex; flex-direction: column; justify-content: center; align-items: center; + + font-family: 'Inter', sans-serif; } h2 { @@ -14,32 +16,77 @@ h2 { #main { border: 2px solid #707070; + padding: 4px; } #admin { border: 2px solid #700070; + padding: 4px; } a { - color: #002ae0; + color: #005ae0; text-decoration: none } a:hover { - color: #002ae0; + color: #005ae0; text-decoration: underline } a:visited { - color: #002ae0; + color: #005ae0; text-decoration: none; } -li.user { +ul { + padding-left: 0; + margin-left: 5px; + margin-right: 5px; + display: flex; + flex-direction: column; + list-style: none; +} + +li.list-item { + list-style: none; display: inline-flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 5px; border-bottom: 1px solid #707070; +} + +li.list-item>p { + margin-top: 5px; + margin-bottom: 5px; +} + +#admin-add-user { + padding: 5px; + border-bottom: 1px solid #707070; +} + +input { + background-color: #0a0a0a; + color: #f0f0f0; + border: 2px solid #707070; +} + +a.user-delete { + color: #c00000; + text-decoration: none; +} + +span.user-disabled { + color: grey; + text-decoration: 1px underline grey; +} + +.row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; } \ No newline at end of file