Rewrite error handling and display
Some checks failed
Push Workflows / rustfmt (push) Successful in 11s
Push Workflows / mdbook (push) Successful in 16s
Push Workflows / mdbook-server (push) Successful in 4m20s
Push Workflows / docs (push) Successful in 5m44s
Push Workflows / clippy (push) Successful in 7m48s
Push Workflows / test (push) Successful in 11m14s
Push Workflows / leptos-test (push) Failing after 11m33s
Push Workflows / build (push) Successful in 12m53s
Push Workflows / nix-build (push) Successful in 16m54s
Push Workflows / docker-build (push) Successful in 17m40s

This commit is contained in:
2025-06-26 00:01:49 +00:00
parent 0541b77b66
commit 368f673fd7
22 changed files with 571 additions and 781 deletions

View File

@ -1,4 +1,5 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
@ -7,14 +8,13 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::models::backend;
}
}
#[server(endpoint = "album/get", client = Client)]
pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError> {
pub async fn get_album(id: i32) -> BackendResult<Option<frontend::Album>> {
use crate::models::backend::Album;
use crate::schema::*;
@ -24,9 +24,7 @@ pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError
.find(id)
.first::<Album>(db_con)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {e}"))
})?;
.context("Error loading album from database")?;
let Some(album) = album else { return Ok(None) };
@ -34,7 +32,8 @@ pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError
.filter(album_artists::album_id.eq(id))
.inner_join(artists::table.on(album_artists::artist_id.eq(artists::id)))
.select(artists::all_columns)
.load(db_con)?;
.load(db_con)
.context("Error loading album artists from database")?;
let img = album
.image_path
@ -52,12 +51,14 @@ pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError
}
#[server(endpoint = "album/get_songs", client = Client)]
pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
pub async fn get_songs(id: i32) -> BackendResult<Vec<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
use std::collections::HashMap;
let user = get_logged_in_user().await?;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
@ -94,7 +95,8 @@ pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
song_dislikes::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
.load(db_con)
.context("Error loading album songs from database")?;
song_list
} else {
let song_list: Vec<(
@ -115,7 +117,8 @@ pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
artists::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
.load(db_con)
.context("Error loading album songs from database")?;
let song_list: Vec<(
backend::Album,

View File

@ -1,7 +1,7 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use cfg_if::cfg_if;
use leptos::prelude::*;
cfg_if! {
if #[cfg(feature = "ssr")] {
@ -27,18 +27,17 @@ pub async fn add_album(
album_title: String,
release_date: Option<String>,
image_path: Option<String>,
) -> Result<(), ServerFnError> {
) -> BackendResult<()> {
use crate::models::backend::NewAlbum;
use crate::schema::albums::{self};
use leptos::server_fn::error::NoCustomError;
let parsed_release_date = match release_date {
Some(date) => match NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d") {
Ok(parsed_date) => Some(parsed_date),
Err(_e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Invalid release date".to_string(),
))
Err(e) => {
return Err(
InputError::InvalidInput(format!("Error parsing release date: {e}")).into(),
);
}
},
None => None,
@ -56,9 +55,7 @@ pub async fn add_album(
diesel::insert_into(albums::table)
.values(&new_album)
.execute(db)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {e}"))
})?;
.context("Error inserting new album into database")?;
Ok(())
}

View File

@ -4,6 +4,7 @@ use cfg_if::cfg_if;
use crate::models::backend::Artist;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! {
@ -26,9 +27,8 @@ cfg_if! {
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "artists/add-artist", client = Client)]
pub async fn add_artist(artist_name: String) -> Result<(), ServerFnError> {
pub async fn add_artist(artist_name: String) -> BackendResult<()> {
use crate::schema::artists::dsl::*;
use leptos::server_fn::error::NoCustomError;
let new_artist = NewArtist { name: artist_name };
@ -36,26 +36,21 @@ pub async fn add_artist(artist_name: String) -> Result<(), ServerFnError> {
diesel::insert_into(artists)
.values(&new_artist)
.execute(db)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding artist: {e}"))
})?;
.context("Error inserting new artist into database")?;
Ok(())
}
#[server(endpoint = "artists/get", client = Client)]
pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFnError> {
pub async fn get_artist_by_id(artist_id: i32) -> BackendResult<Option<Artist>> {
use crate::schema::artists::dsl::*;
use leptos::server_fn::error::NoCustomError;
let db = &mut get_db_conn();
let artist = artists
.filter(id.eq(artist_id))
.first::<Artist>(db)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {e}"))
})?;
.context("Error loading artist from database")?;
Ok(artist)
}
@ -64,18 +59,12 @@ pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFn
pub async fn top_songs_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<(frontend::Song, i64)>, ServerFnError> {
) -> BackendResult<Vec<(frontend::Song, i64)>> {
use crate::api::auth::get_user;
use crate::models::backend::Song;
use crate::schema::*;
use leptos::server_fn::error::NoCustomError;
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::ServerError::<NoCustomError>(format!("Error getting user: {e}"))
})?
.id;
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let db = &mut get_db_conn();
let song_play_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
@ -175,8 +164,8 @@ pub async fn top_songs_by_artist(
let plays = song_play_counts
.get(&song.id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
top_songs_map.insert(song.id, (songdata, *plays));
@ -192,7 +181,7 @@ pub async fn top_songs_by_artist(
pub async fn albums_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<frontend::Album>, ServerFnError> {
) -> BackendResult<Vec<frontend::Album>> {
use crate::schema::*;
let db = &mut get_db_conn();
@ -219,7 +208,8 @@ pub async fn albums_by_artist(
.on(albums::id.eq(album_artists::album_id)),
)
.select((albums::all_columns, artists::all_columns))
.load(db)?;
.load(db)
.context("Error loading album artists from database")?;
for (album, artist) in album_artists {
if let Some(stored_album) = albums_map.get_mut(&album.id) {

View File

@ -4,7 +4,6 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use leptos_axum::extract;
use axum_login::AuthSession;
use crate::util::auth_backend::AuthBackend;
@ -13,18 +12,17 @@ cfg_if! {
use crate::api::users::UserCredentials;
use crate::models::backend::{NewUser, User};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
/// Create a new user and log them in
/// Takes in a NewUser struct, with the password in plaintext
/// Returns a Result with the error message if the user could not be created
#[server(endpoint = "signup", client = Client)]
pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
pub async fn signup(new_user: NewUser) -> BackendResult<()> {
// Check LIBRETUNES_DISABLE_SIGNUP env var
if std::env::var("LIBRETUNES_DISABLE_SIGNUP").is_ok_and(|v| v == "true") {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Signup is disabled".to_string(),
));
return Err(AuthError::SignupDisabled.into());
}
use crate::api::users::create_user;
@ -35,13 +33,13 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
..new_user
};
create_user(&new_user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
create_user(&new_user)
.await
.context("Error creating user")?;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
@ -49,15 +47,12 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
};
match auth_session.authenticate(credentials).await {
Ok(Some(user)) => auth_session.login(&user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
}),
Ok(None) => Err(ServerFnError::<NoCustomError>::ServerError(
"Error authenticating user: User not found".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error authenticating user: {e}"
))),
Ok(Some(user)) => auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")).into()),
Ok(None) => Err(AuthError::InvalidCredentials.into()),
Err(e) => Err(AuthError::AuthError(format!("Error authenticating user: {e}")).into()),
}
}
@ -65,21 +60,22 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
/// Takes in a username or email and a password in plaintext
/// Returns a Result with a boolean indicating if the login was successful
#[server(endpoint = "login", client = Client)]
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
pub async fn login(credentials: UserCredentials) -> BackendResult<Option<User>> {
use crate::api::users::validate_user;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = validate_user(credentials).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {e}"))
})?;
let user = validate_user(credentials)
.await
.context("Error validating user credentials")?;
if let Some(mut user) = user {
auth_session.login(&user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
})?;
auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")))?;
user.password = None;
Ok(Some(user))
@ -91,14 +87,15 @@ pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerF
/// Log a user out
/// Returns a Result with the error message if the user could not be logged out
#[server(endpoint = "logout", client = Client)]
pub async fn logout() -> Result<(), ServerFnError> {
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn logout() -> BackendResult<()> {
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session.logout().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
auth_session
.logout()
.await
.map_err(|e| AuthError::AuthError(format!("Error logging out user: {e}")))?;
leptos_axum::redirect("/login");
Ok(())
@ -107,10 +104,10 @@ pub async fn logout() -> Result<(), ServerFnError> {
/// Check if a user is logged in
/// Returns a Result with a boolean indicating if the user is logged in
#[server(endpoint = "check_auth", client = Client)]
pub async fn check_auth() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn check_auth() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.is_some())
}
@ -129,16 +126,17 @@ pub async fn check_auth() -> Result<bool, ServerFnError> {
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_auth() -> Result<(), ServerFnError> {
check_auth().await.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
}
})
pub async fn require_auth() -> BackendResult<()> {
check_auth()
.await
.context("Error checking authentication")
.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(AuthError::Unauthorized.into())
}
})
}
/// Get the current logged-in user
@ -157,23 +155,19 @@ pub async fn require_auth() -> Result<(), ServerFnError> {
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn get_user() -> Result<User, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn get_user() -> BackendResult<User> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session
.user
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"User not logged in".to_string(),
))
auth_session.user.ok_or(AuthError::Unauthorized.into())
}
#[server(endpoint = "get_logged_in_user", client = Client)]
pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn get_logged_in_user() -> BackendResult<Option<User>> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = auth_session.user.map(|mut user| {
user.password = None;
@ -186,10 +180,10 @@ pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
/// Check if a user is an admin
/// Returns a Result with a boolean indicating if the user is logged in and an admin
#[server(endpoint = "check_admin", client = Client)]
pub async fn check_admin() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn check_admin() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
}
@ -208,14 +202,12 @@ pub async fn check_admin() -> Result<bool, ServerFnError> {
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_admin() -> Result<(), ServerFnError> {
pub async fn require_admin() -> BackendResult<()> {
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
Err(AuthError::AdminRequired.into())
}
})
}

View File

@ -1,23 +1,23 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use crate::util::serverfn_client::Client;
#[server(endpoint = "health", client = Client)]
pub async fn health() -> Result<String, ServerFnError> {
pub async fn health() -> BackendResult<String> {
use crate::util::database::get_db_conn;
use crate::util::redis::get_redis_conn;
use diesel::connection::SimpleConnection;
use server_fn::error::NoCustomError;
use tower_sessions_redis_store::fred::interfaces::ClientLike;
get_db_conn()
.batch_execute("SELECT 1")
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Database error: {e}")))?;
.context("Failed to execute database health check query")?;
get_redis_conn()
.ping::<()>(None)
.await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Redis error: {e}")))?;
.map_err(|e| BackendError::InternalError(format!("{e}")))
.context("Failed to execute Redis health check ping")?;
Ok("ok".to_string())
}

View File

@ -1,5 +1,6 @@
use crate::models::backend::HistoryEntry;
use crate::models::backend::Song;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime;
use leptos::prelude::*;
@ -8,7 +9,6 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::api::auth::get_user;
}
@ -16,35 +16,39 @@ cfg_if! {
/// Get the history of the current user.
#[server(endpoint = "history/get", client = Client)]
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, ServerFnError> {
let user = get_user().await?;
pub async fn get_history(limit: Option<i64>) -> BackendResult<Vec<HistoryEntry>> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let history = user.get_history(limit, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting history: {e}"))
})?;
let history = user
.get_history(limit, db_con)
.context("Error getting history")?;
Ok(history)
}
/// Get the listen dates and songs of the current user.
#[server(endpoint = "history/get_songs", client = Client)]
pub async fn get_history_songs(
limit: Option<i64>,
) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> {
let user = get_user().await?;
pub async fn get_history_songs(limit: Option<i64>) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let songs = user.get_history_songs(limit, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting history songs: {e}"))
})?;
let songs = user
.get_history_songs(limit, db_con)
.context("Error getting history songs")?;
Ok(songs)
}
/// Add a song to the history of the current user.
#[server(endpoint = "history/add", client = Client)]
pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> {
let user = get_user().await?;
pub async fn add_history(song_id: i32) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
user.add_history(song_id, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding history: {e}"))
})?;
user.add_history(song_id, db_con)
.context("Error adding song to history")?;
Ok(())
}

View File

@ -1,4 +1,5 @@
use crate::models::{backend, frontend};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use cfg_if::cfg_if;
use leptos::prelude::*;
@ -11,14 +12,13 @@ cfg_if! {
use crate::util::database::get_db_conn;
use crate::util::extract_field::extract_field;
use std::collections::HashMap;
use server_fn::error::NoCustomError;
use log::*;
use crate::schema::*;
}
}
#[cfg(feature = "ssr")]
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> Result<bool, ServerFnError> {
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> BackendResult<bool> {
let mut db_conn = get_db_conn();
let exists = playlists::table
@ -27,22 +27,15 @@ async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> Result<bool, Serv
.select(playlists::id)
.first::<i32>(&mut db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?
.context("Error loading playlist from database")?
.is_some();
Ok(exists)
}
#[server(endpoint = "playlists/get_all", client = Client)]
pub async fn get_playlists() -> Result<Vec<backend::Playlist>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlists() -> BackendResult<Vec<backend::Playlist>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
@ -50,21 +43,14 @@ pub async fn get_playlists() -> Result<Vec<backend::Playlist>, ServerFnError> {
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.load::<backend::Playlist>(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlists: {e}"))
})?;
.context("Error loading playlists from database")?;
Ok(playlists)
}
#[server(endpoint = "playlists/get", client = Client)]
pub async fn get_playlist(playlist_id: i32) -> Result<backend::Playlist, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlist(playlist_id: i32) -> BackendResult<backend::Playlist> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
@ -73,33 +59,23 @@ pub async fn get_playlist(playlist_id: i32) -> Result<backend::Playlist, ServerF
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.first(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlist: {e}"))
})?;
.context("Error loading playlist from database")?;
Ok(playlist)
}
#[server(endpoint = "playlists/get_songs", client = Client)]
pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlist_songs(playlist_id: i32) -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user_id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
@ -139,7 +115,8 @@ pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>,
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading playlist songs from database")?;
let mut playlist_songs: HashMap<i32, frontend::Song> = HashMap::new();
@ -185,24 +162,19 @@ pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>,
}
#[server(endpoint = "playlists/add_song", client = Client)]
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> Result<(), ServerFnError> {
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
@ -213,23 +185,17 @@ pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> Result<(),
playlist_songs::song_id.eq(song_id),
))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error adding song to playlist: {e}"
))
})?;
.context("Error adding song to playlist in database")?;
Ok(())
}
#[server(input = MultipartFormData, endpoint = "playlists/create")]
pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn create_playlist(data: MultipartData) -> BackendResult<()> {
use crate::models::backend::NewPlaylist;
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -246,20 +212,17 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
}
"picture" => {
// Read the image
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting field bytes: {e}"
))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error creating image resource: {e}"
))
})?;
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
@ -272,7 +235,7 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
// Unwrap mandatory fields
let name = playlist_name.ok_or_else(|| {
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
InputError::MissingField("name".to_string()).context("Missing playlist name")
})?;
let new_playlist = NewPlaylist {
@ -288,33 +251,27 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
let playlist = diesel::insert_into(playlists::table)
.values(&new_playlist)
.get_result::<backend::Playlist>(db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating playlist: {e}"))
})?;
.context("Error creating playlist in database")?;
// If a picture was provided, save it to the database
if let Some(image_source) = picture_data {
let image_path = format!("assets/images/playlist/{}.webp", playlist.id);
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting image to webp: {e}"
))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok::<(), ServerFnError>(())
Ok::<(), BackendError>(())
})
}
#[server(input = MultipartFormData, endpoint = "playlists/edit_image")]
pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn edit_playlist_image(data: MultipartData) -> BackendResult<()> {
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -331,20 +288,17 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
}
"picture" => {
// Read the image
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting field bytes: {e}"
))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error creating image resource: {e}"
))
})?;
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
@ -356,25 +310,22 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
}
// Unwrap mandatory fields
let playlist_id = playlist_id.ok_or_else(|| {
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
})?;
let playlist_id = playlist_id
.ok_or_else(|| InputError::MissingField("id".to_string()).context("Missing playlist ID"))?;
let playlist_id: i32 = playlist_id.parse().map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Invalid playlist ID: {e}"))
})?;
let playlist_id: i32 = playlist_id
.parse()
.map_err(|e| InputError::InvalidInput(format!("Invalid playlist ID: {e}")))
.context("Error parsing playlist ID from string")?;
// Make sure the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
// If a picture was provided, save it to the database
@ -382,65 +333,52 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
let image_path = format!("assets/images/playlist/{playlist_id}.webp");
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting image to webp: {e}"
))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok(())
}
#[server(endpoint = "playlists/delete", client = Client)]
pub async fn delete_playlist(playlist_id: i32) -> Result<(), ServerFnError> {
pub async fn delete_playlist(playlist_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
diesel::delete(playlists::table.find(playlist_id))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error deleting playlist: {e}"))
})?;
.context("Error deleting playlist from database")?;
Ok(())
}
#[server(endpoint = "playlists/rename", client = Client)]
pub async fn rename_playlist(id: i32, new_name: String) -> Result<(), ServerFnError> {
pub async fn rename_playlist(id: i32, new_name: String) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, id).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
let valid_playlist = user_owns_playlist(user.id, id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized.into());
}
let mut db_conn = get_db_conn();
@ -448,9 +386,7 @@ pub async fn rename_playlist(id: i32, new_name: String) -> Result<(), ServerFnEr
diesel::update(playlists::table.find(id))
.set(playlists::name.eq(new_name))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error renaming playlist: {e}"))
})?;
.context("Error renaming playlist in database")?;
Ok(())
}

View File

@ -1,8 +1,8 @@
use crate::util::error::*;
use cfg_if::cfg_if;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime;
@ -10,7 +10,6 @@ use chrono::NaiveDateTime;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
@ -25,46 +24,48 @@ cfg_if! {
/// Handle a user uploading a profile picture. Converts the image to webp and saves it to the server.
#[server(input = MultipartFormData, endpoint = "/profile/upload_picture")]
pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn upload_picture(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
let field = data
.next_field()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field: {e}"))
})?
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("No field found".to_string()))?;
.map_err(|e| InputError::InvalidInput(format!("Error reading multipart data: {e}")))
.context("Error getting next field from multipart data")?
.ok_or_else(|| {
InputError::InvalidInput("Expected a field in the multipart data".to_string())
})?;
if field.name() != Some("picture") {
return Err(ServerFnError::ServerError(
"Field name is not 'picture'".to_string(),
));
return Err(InputError::InvalidInput(format!(
"Expected field 'picture', got '{:?}'",
field.name()
))
.into());
}
// Get user id from session
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Read the image, and convert it to webp
use image_convert::{to_webp, ImageResource, WEBPConfig};
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field bytes: {e}"))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::InvalidInput(format!("Error reading bytes from field: {e}")))
.context("Error reading bytes of the picture field")?;
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating image resource: {e}"))
})?;
let image_source = ImageResource::from_reader(reader)
.map_err(|e| InputError::InvalidInput(format!("Error creating image resource: {e}")))
.context("Error creating image resource from reader")?;
let profile_picture_path = format!("assets/images/profile/{}.webp", user.id);
let mut image_target = ImageResource::from_path(&profile_picture_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error converting image to webp: {e}"))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("Error converting image to webp: {e}")))?;
Ok(())
}
@ -78,13 +79,8 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn recent_songs(
for_user_id: i32,
limit: Option<i64>,
) -> Result<Vec<(NaiveDateTime, frontend::Song)>, ServerFnError> {
let viewing_user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
) -> BackendResult<Vec<(NaiveDateTime, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_con = get_db_conn();
@ -138,7 +134,8 @@ pub async fn recent_songs(
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
.load(&mut db_con)
.context("Error loading recent songs from database")?;
// Process the history data into a map of song ids to song data
let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
@ -201,13 +198,8 @@ pub async fn top_songs(
start_date: NaiveDateTime,
end_date: NaiveDateTime,
limit: Option<i64>,
) -> Result<Vec<(i64, frontend::Song)>, ServerFnError> {
let viewing_user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
) -> BackendResult<Vec<(i64, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_con = get_db_conn();
@ -220,14 +212,16 @@ pub async fn top_songs(
.select((song_history::song_id, count(song_history::song_id)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_con)?
.load(&mut db_con)
.context("Error loading top song ids and counts from database")?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.load(&mut db_con)?
.load(&mut db_con)
.context("Error loading top song ids and counts from database")?
};
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
@ -265,7 +259,8 @@ pub async fn top_songs(
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
.load(&mut db_con)
.context("Error loading top songs from database")?;
// Process the history data into a map of song ids to song data
let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> =
@ -308,8 +303,8 @@ pub async fn top_songs(
let plays = history_counts
.get(&song.id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
history_songs_map.insert(song.id, (*plays, songdata));
@ -332,7 +327,7 @@ pub async fn top_artists(
start_date: NaiveDateTime,
end_date: NaiveDateTime,
limit: Option<i64>,
) -> Result<Vec<(i64, frontend::Artist)>, ServerFnError> {
) -> BackendResult<Vec<(i64, frontend::Artist)>> {
let mut db_con = get_db_conn();
let artist_counts: Vec<(i64, Artist)> = if let Some(limit) = limit {
@ -345,7 +340,8 @@ pub async fn top_artists(
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.limit(limit)
.load(&mut db_con)?
.load(&mut db_con)
.context("Error loading top artists from database")?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
@ -355,7 +351,8 @@ pub async fn top_artists(
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.load(&mut db_con)?
.load(&mut db_con)
.context("Error loading top artists from database")?
};
let artist_data: Vec<(i64, frontend::Artist)> = artist_counts
@ -376,13 +373,8 @@ pub async fn top_artists(
}
#[server(endpoint = "/profile/liked_songs", client = Client)]
pub async fn get_liked_songs() -> Result<Vec<frontend::Song>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_liked_songs() -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
@ -407,7 +399,8 @@ pub async fn get_liked_songs() -> Result<Vec<frontend::Song>, ServerFnError> {
albums::all_columns.nullable(),
artists::all_columns.nullable(),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading liked songs from database")?;
let mut liked_songs: HashMap<i32, frontend::Song> = HashMap::new();

View File

@ -1,4 +1,5 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
@ -49,7 +50,7 @@ pub type SearchResults<T> = Vec<(T, f32)>;
pub async fn search_albums(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Album>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Album>> {
use crate::schema::*;
let mut db_conn = get_db_conn();
@ -59,7 +60,8 @@ pub async fn search_albums(
.order_by(trgm_distance(albums::title, query.clone()).desc())
.limit(limit)
.select(albums::id)
.load::<i32>(&mut db_conn)?;
.load::<i32>(&mut db_conn)
.context("Error loading album ids from database")?;
let mut albums_map: HashMap<i32, (frontend::Album, f32)> = HashMap::new();
@ -75,7 +77,8 @@ pub async fn search_albums(
artists::all_columns,
trgm_distance(albums::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading album artists from database")?;
for (album, artist, score) in album_artists {
if let Some((stored_album, _score)) = albums_map.get_mut(&album.id) {
@ -113,7 +116,7 @@ pub async fn search_albums(
pub async fn search_artists(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Artist>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Artist>> {
use crate::schema::*;
let mut db_conn = get_db_conn();
@ -123,7 +126,8 @@ pub async fn search_artists(
.order_by(trgm_distance(artists::name, query.clone()).desc())
.limit(limit)
.select((artists::all_columns, trgm_distance(artists::name, query)))
.load::<(backend::Artist, f32)>(&mut db_conn)?;
.load::<(backend::Artist, f32)>(&mut db_conn)
.context("Error loading artists from database")?;
let artist_data = artist_list
.into_iter()
@ -154,11 +158,13 @@ pub async fn search_artists(
pub async fn search_songs(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Song>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
let user = get_logged_in_user().await?;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let mut db_conn = get_db_conn();
@ -198,7 +204,8 @@ pub async fn search_songs(
song_dislikes::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
} else {
@ -223,7 +230,8 @@ pub async fn search_songs(
artists::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
.into_iter()
@ -293,14 +301,11 @@ pub async fn search_songs(
pub async fn search(
query: String,
limit: i64,
) -> Result<
(
SearchResults<frontend::Album>,
SearchResults<frontend::Artist>,
SearchResults<frontend::Song>,
),
ServerFnError,
> {
) -> BackendResult<(
SearchResults<frontend::Album>,
SearchResults<frontend::Artist>,
SearchResults<frontend::Song>,
)> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query, limit);
@ -308,5 +313,10 @@ pub async fn search(
use tokio::join;
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
let albums = albums.context("Error searching for albums")?;
let artists = artists.context("Error searching for artists")?;
let songs = songs.context("Error searching for songs")?;
Ok((albums, artists, songs))
}

View File

@ -3,11 +3,11 @@ use leptos::prelude::*;
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::api::auth::get_user;
use crate::models::backend::{Song, Album, Artist};
@ -17,66 +17,56 @@ cfg_if! {
/// Like or unlike a song
#[server(endpoint = "songs/set_like", client = Client)]
pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn set_like_song(song_id: i32, like: bool) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
user.set_like_song(song_id, like, db_con)
.await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error liking song: {e}")))
.context("Error setting like status for song")
}
/// Dislike or remove dislike from a song
#[server(endpoint = "songs/set_dislike", client = Client)]
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
user.set_dislike_song(song_id, dislike, db_con)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error disliking song: {e}"))
})
.context("Error setting dislike status for song")
}
/// Get the like and dislike status of a song
#[server(endpoint = "songs/get_like_dislike", client = Client)]
pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn get_like_dislike_song(song_id: i32) -> BackendResult<(bool, bool)> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
// TODO this could probably be done more efficiently with a tokio::try_join, but
// doing so is much more complicated than it would initially seem
let like = user.get_like_song(song_id, db_con).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song liked: {e}"))
})?;
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song disliked: {e}"))
})?;
let like = user
.get_like_song(song_id, db_con)
.await
.context("Error getting song like status")?;
let dislike = user
.get_dislike_song(song_id, db_con)
.await
.context("Error getting song dislike status")?;
Ok((like, dislike))
}
#[server(endpoint = "songs/get", client = Client)]
pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, ServerFnError> {
pub async fn get_song_by_id(song_id: i32) -> BackendResult<Option<frontend::Song>> {
use crate::schema::*;
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let db_con = &mut get_db_conn();
@ -111,7 +101,8 @@ pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, Serv
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db_con)?;
.load(db_con)
.context("Error loading song from database")?;
let song = song_parts.first().cloned();
let artists = song_parts
@ -148,7 +139,7 @@ pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, Serv
}
#[server(endpoint = "songs/plays", client = Client)]
pub async fn get_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
pub async fn get_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let db_con = &mut get_db_conn();
@ -157,23 +148,16 @@ pub async fn get_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
.filter(song_history::song_id.eq(song_id))
.count()
.get_result::<i64>(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
})?;
.context("Error getting song plays")?;
Ok(plays)
}
#[server(endpoint = "songs/my-plays", client = Client)]
pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
pub async fn get_my_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let db_con = &mut get_db_conn();
@ -185,9 +169,7 @@ pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
)
.count()
.get_result::<i64>(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
})?;
.context("Error getting song plays for user")?;
Ok(plays)
}

View File

@ -1,3 +1,4 @@
use crate::util::error::*;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
@ -10,7 +11,6 @@ cfg_if! {
use crate::util::extract_field::extract_field;
use diesel::prelude::*;
use log::*;
use server_fn::error::NoCustomError;
use chrono::NaiveDate;
}
}
@ -18,7 +18,7 @@ cfg_if! {
/// Validate the artist ids in a multipart field
/// Expects a field with a comma-separated list of artist ids, and ensures each is a valid artist id in the database
#[cfg(feature = "ssr")]
async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, ServerFnError> {
async fn validate_artist_ids(artist_ids: Field<'static>) -> BackendResult<Vec<i32>> {
use crate::models::backend::Artist;
use diesel::result::Error::NotFound;
@ -40,32 +40,24 @@ async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, Ser
match artist {
Ok(_) => Ok(artist_id),
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
"Artist does not exist".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error finding artist id: {e}"
))),
Err(NotFound) => Err(AccessError::NotFound.context("Artist not found")),
Err(e) => Err(e.context("Error finding artist id")),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing artist id".to_string(),
))
Err(InputError::InvalidInput("Error parsing artist id".to_string()).into())
}
})
.collect()
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading artist id: {e}"
))),
Err(e) => Err(InputError::FieldReadError(format!("Error reading artist ids: {e}")).into()),
}
}
/// Validate the album id in a multipart field
/// Expects a field with an album id, and ensures it is a valid album id in the database
#[cfg(feature = "ssr")]
async fn validate_album_id(album_id: Field<'static>) -> Result<Option<i32>, ServerFnError> {
async fn validate_album_id(album_id: Field<'static>) -> BackendResult<Option<i32>> {
use crate::models::backend::Album;
use diesel::result::Error::NotFound;
@ -86,29 +78,21 @@ async fn validate_album_id(album_id: Field<'static>) -> Result<Option<i32>, Serv
match album {
Ok(_) => Ok(Some(album_id)),
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
"Album does not exist".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error finding album id: {e}"
))),
Err(NotFound) => Err(AccessError::NotFound.context("Album not found")),
Err(e) => Err(e.context("Error finding album id")),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing album id".to_string(),
))
Err(InputError::InvalidInput("Error parsing album id".to_string()).into())
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading album id: {e}"
))),
Err(e) => Err(InputError::FieldReadError(format!("Error reading album id: {e}")).into()),
}
}
/// Validate the track number in a multipart field
/// Expects a field with a track number, and ensures it is a valid track number (non-negative integer)
#[cfg(feature = "ssr")]
async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i32>, ServerFnError> {
async fn validate_track_number(track_number: Field<'static>) -> BackendResult<Option<i32>> {
match track_number.text().await {
Ok(track_number) => {
if track_number.is_empty() {
@ -117,30 +101,27 @@ async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i3
if let Ok(track_number) = track_number.parse::<i32>() {
if track_number < 0 {
Err(ServerFnError::<NoCustomError>::ServerError(
"Track number must be positive or 0".to_string(),
))
Err(
InputError::InvalidInput("Track number must be positive or 0".to_string())
.into(),
)
} else {
Ok(Some(track_number))
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing track number".to_string(),
))
Err(InputError::InvalidInput("Error parsing track number".to_string()).into())
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading track number: {e}"
)))?,
Err(e) => {
Err(InputError::FieldReadError(format!("Error reading track number: {e}")).into())
}
}
}
/// Validate the release date in a multipart field
/// Expects a field with a release date, and ensures it is a valid date in the format [year]-[month]-[day]
#[cfg(feature = "ssr")]
async fn validate_release_date(
release_date: Field<'static>,
) -> Result<Option<NaiveDate>, ServerFnError> {
async fn validate_release_date(release_date: Field<'static>) -> BackendResult<Option<NaiveDate>> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
@ -151,20 +132,19 @@ async fn validate_release_date(
match release_date {
Ok(release_date) => Ok(Some(release_date)),
Err(_) => Err(ServerFnError::<NoCustomError>::ServerError(
"Invalid release date".to_string(),
)),
Err(_) => Err(InputError::InvalidInput(
"Invalid release date format, expected YYYY-MM-DD".to_string(),
)
.into()),
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading release date: {e}"
))),
Err(e) => Err(InputError::InvalidInput(format!("Error reading release date: {e}")).into()),
}
}
/// Handle the file upload form
#[server(input = MultipartFormData, endpoint = "/upload")]
pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn upload(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -182,19 +162,39 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
match name.as_str() {
"title" => {
title = Some(extract_field(field).await?);
title = Some(
extract_field(field)
.await
.context("Error extracting title field")?,
);
}
"artist_ids" => {
artist_ids = Some(validate_artist_ids(field).await?);
artist_ids = Some(
validate_artist_ids(field)
.await
.context("Error validating artist ids")?,
);
}
"album_id" => {
album_id = Some(validate_album_id(field).await?);
album_id = Some(
validate_album_id(field)
.await
.context("Error validating album id")?,
);
}
"track_number" => {
track = Some(validate_track_number(field).await?);
track = Some(
validate_track_number(field)
.await
.context("Error validating track number")?,
);
}
"release_date" => {
release_date = Some(validate_release_date(field).await?);
release_date = Some(
validate_release_date(field)
.await
.context("Error validating release date")?,
);
}
"file" => {
use crate::util::audio::extract_metadata;
@ -206,11 +206,9 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
// or behavior that we may wish to change in the future
// Create file name
let title = title
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Title field required and must precede file field".to_string(),
))?;
let title = title.clone().ok_or(InputError::InvalidInput(
"Title field must be present and must precede file field".to_string(),
))?;
let clean_title = title.replace(" ", "_").replace("/", "_");
let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
@ -226,28 +224,30 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
.write(true)
.create(true)
.truncate(true)
.open(upload_path.clone())?;
.open(upload_path.clone())
.context("Error opening file for upload")?;
while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?;
while let Some(chunk) = field.chunk().await.map_err(|e| {
InputError::FieldReadError(format!("Error reading file chunk: {e}"))
})? {
file.write_all(&chunk)
.context("Error writing field chunk to file")?;
}
file.flush()?;
file.flush().context("Error flusing file")?;
// Rewind the file so the duration can be measured
file.rewind()?;
file.rewind().context("Error rewinding file")?;
// Get the codec and duration of the file
let (file_codec, file_duration) = extract_metadata(file).map_err(|e| {
let msg = format!("Error measuring duration of audio file {upload_path}: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
let (file_codec, file_duration) = extract_metadata(file)
.context("Error extracting metadata from uploaded file")?;
if file_codec != CODEC_TYPE_MP3 {
let msg = format!("Invalid uploaded audio file codec: {file_codec}");
warn!("{}", msg);
return Err(ServerFnError::<NoCustomError>::ServerError(msg));
return Err(InputError::InvalidInput(format!(
"Invalid uploaded audio file codec: {file_codec}"
))
.into());
}
duration = Some(file_duration);
@ -259,30 +259,23 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
}
// Unwrap mandatory fields
let title = title.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing title".to_string(),
))?;
let title = title.ok_or(InputError::MissingField("title".to_string()))?;
let artist_ids = artist_ids.unwrap_or(vec![]);
let file_name = file_name.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing file".to_string(),
))?;
let duration = duration.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing duration".to_string(),
))?;
let duration = i32::try_from(duration).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting duration to i32: {e}"
))
})?;
let file_name = file_name.ok_or(InputError::MissingField("file".to_string()))?;
let duration = duration.ok_or(InputError::MissingField("duration".to_string()))?;
let duration = i32::try_from(duration)
.map_err(|e| InputError::InvalidInput(format!("Error parsing duration: {e}")))
.context("Error converting duration to i32")?;
let album_id = album_id.unwrap_or(None);
let track = track.unwrap_or(None);
let release_date = release_date.unwrap_or(None);
if album_id.is_some() != track.is_some() {
return Err(ServerFnError::<NoCustomError>::ServerError(
return Err(InputError::InvalidInput(
"Album id and track number must both be present or both be absent".to_string(),
));
)
.into());
}
// Create the song
@ -302,11 +295,7 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
let song = song
.insert_into(crate::schema::songs::table)
.get_result::<Song>(db_con)
.map_err(|e| {
let msg = format!("Error saving song to database: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
.context("Error adding song to database")?;
// Save the song's artists to the database
use crate::schema::song_artists;
@ -325,11 +314,7 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
diesel::insert_into(crate::schema::song_artists::table)
.values(&artist_ids)
.execute(db_con)
.map_err(|e| {
let msg = format!("Error saving song artists to database: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
.context("Error saving song artists to database")?;
Ok(())
}

View File

@ -16,6 +16,7 @@ cfg_if::cfg_if! {
}
use crate::models::backend::User;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
@ -29,9 +30,8 @@ pub struct UserCredentials {
/// Get a user from the database by username or email
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn find_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
pub async fn find_user(username_or_email: String) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
// Look for either a username or email that matches the input, and return an option with None if no user is found
let db_con = &mut get_db_conn();
@ -40,11 +40,7 @@ pub async fn find_user(username_or_email: String) -> Result<Option<User>, Server
.or_filter(email.eq(username_or_email))
.first::<User>(db_con)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error loading user from database")?;
Ok(user)
}
@ -52,20 +48,15 @@ pub async fn find_user(username_or_email: String) -> Result<Option<User>, Server
/// Get a user from the database by ID
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn find_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
pub async fn find_user_by_id(user_id: i32) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
let db_con = &mut get_db_conn();
let user = users
.filter(id.eq(user_id))
.first::<User>(db_con)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error loading user from database")?;
Ok(user)
}
@ -73,25 +64,21 @@ pub async fn find_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError
/// Create a new user in the database
/// Returns an empty Result if successful, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
pub async fn create_user(new_user: &NewUser) -> BackendResult<()> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
let new_password =
new_user
.password
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
"No password provided for user {}",
new_user.username
.ok_or(BackendError::InputError(InputError::MissingField(
"password".to_string(),
)))?;
let salt = SaltString::generate(&mut OsRng);
let password_hash = Pbkdf2
.hash_password(new_password.as_bytes(), &salt)
.map_err(|_| {
ServerFnError::<NoCustomError>::ServerError("Error hashing password".to_string())
})?
.map_err(|e| AuthError::AuthError(format!("Error hashing password: {e}")))?
.to_string();
let new_user = NewUser {
@ -104,9 +91,7 @@ pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
diesel::insert_into(users)
.values(&new_user)
.execute(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
.context("Error inserting new user into database")?;
Ok(())
}
@ -114,16 +99,10 @@ pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
/// Validate a user's credentials
/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
use leptos::server_fn::error::NoCustomError;
pub async fn validate_user(credentials: UserCredentials) -> BackendResult<Option<User>> {
let db_user = find_user(credentials.username_or_email.clone())
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error finding user in database")?;
// If the user is not found, return None
let db_user = match db_user {
@ -131,18 +110,13 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
None => return Ok(None),
};
let db_password =
db_user
.password
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
"No password found for user {}",
db_user.username
)))?;
let db_password = db_user.password.clone().ok_or(AuthError::AuthError(
"No password stored for user".to_string(),
))?;
let password_hash = PasswordHash::new(&db_password).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error hashing supplied password: {e}"))
})?;
let password_hash = PasswordHash::new(&db_password)
.map_err(|e| AuthError::AuthError(format!("{e}")))
.context("Error parsing password hash from database")?;
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
Ok(()) => {}
@ -150,9 +124,9 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
return Ok(None);
}
Err(e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error verifying password: {e}"
)));
return Err(
AuthError::AuthError(format!("{e}")).context("Error verifying password hash")
);
}
}
@ -162,8 +136,10 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
/// Get a user from the database by username or email
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[server(endpoint = "find_user", client = Client)]
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
let mut user = find_user(username_or_email).await?;
pub async fn get_user(username_or_email: String) -> BackendResult<Option<User>> {
let mut user = find_user(username_or_email)
.await
.context("Error finding user by username or email")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
@ -174,8 +150,10 @@ pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerF
}
#[server(endpoint = "get_user_by_id", client = Client)]
pub async fn get_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
let mut user = find_user_by_id(user_id).await?;
pub async fn get_user_by_id(user_id: i32) -> BackendResult<Option<User>> {
let mut user = find_user_by_id(user_id)
.await
.context("Error finding user by ID")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {

View File

@ -2,6 +2,7 @@ use crate::components::error::Error;
use crate::components::loading::*;
use crate::components::menu::*;
use crate::util::state::GlobalState;
use leptos::either::*;
use leptos::html::Div;
use leptos::prelude::*;
use leptos_icons::*;
@ -121,49 +122,38 @@ pub fn Playlists() -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| {
errors.get().into_iter().map(|(_id, error)| {
view! {
<Error<String>
message=error.to_string()
/>
}
}).collect::<Vec<_>>()
<A href={"/liked".to_string()} {..}
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
>
<img class="w-15 h-15 rounded-xl p-2"
src="/assets/images/placeholders/MusicPlaceholder.svg" />
<h2 class="pr-3 my-2">"Liked Songs"</h2>
</A>
{move || GlobalState::playlists().get().map(|playlists| {
match playlists {
Ok(playlists) => Either::Left(view! {
{playlists.into_iter().map(|playlist| {
let active = Signal::derive(move || {
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
});
view! {
<A href={format!("/playlist/{}", playlist.id)} {..}
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
<img class="w-15 h-15 rounded-xl p-2 object-cover"
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<h2 class="pr-3 my-2">{playlist.name}</h2>
</A>
}
}).collect::<Vec<_>>()}
}),
Err(error) => Either::Right(error.to_component()),
}
>
<A href={"/liked".to_string()} {..}
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
>
<img class="w-15 h-15 rounded-xl p-2"
src="/assets/images/placeholders/MusicPlaceholder.svg" />
<h2 class="pr-3 my-2">"Liked Songs"</h2>
</A>
{move || GlobalState::playlists().get().map(|playlists| {
playlists.map(|playlists| {
view! {
{playlists.into_iter().map(|playlist| {
let active = Signal::derive(move || {
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
});
view! {
<A href={format!("/playlist/{}", playlist.id)} {..}
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
<img class="w-15 h-15 rounded-xl p-2 object-cover"
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<h2 class="pr-3 my-2">{playlist.name}</h2>
</A>
}
}).collect::<Vec<_>>()}
}
})
})}
</ErrorBoundary>
})}
</Transition>
</div>
</div>

View File

@ -8,8 +8,8 @@ cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::*;
use std::error::Error;
use crate::models::backend::{Song, HistoryEntry};
use crate::util::error::*;
}
}
@ -59,7 +59,7 @@ impl User {
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<HistoryEntry>, Box<dyn Error>> {
) -> BackendResult<Vec<HistoryEntry>> {
use crate::schema::song_history::dsl::*;
let my_history = if let Some(limit) = limit {
@ -67,7 +67,8 @@ impl User {
.filter(user_id.eq(self.id))
.order(date.desc())
.limit(limit)
.load(conn)?
.load(conn)
.context("Error getting user history")?
} else {
song_history.filter(user_id.eq(self.id)).load(conn)?
};
@ -96,7 +97,7 @@ impl User {
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
use crate::schema::song_history::dsl::*;
use crate::schema::songs::dsl::*;
@ -107,14 +108,16 @@ impl User {
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)?
.load(conn)
.context("Error getting user history songs")?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(self.id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)?
.load(conn)
.context("Error getting user history songs")?
};
Ok(my_history)
@ -135,7 +138,7 @@ impl User {
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_history(&self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
pub fn add_history(&self, song_id: i32, conn: &mut PgPooledConn) -> BackendResult<()> {
use crate::schema::song_history;
diesel::insert_into(song_history::table)
@ -143,7 +146,8 @@ impl User {
song_history::user_id.eq(self.id),
song_history::song_id.eq(song_id),
))
.execute(conn)?;
.execute(conn)
.context("Error adding song to history")?;
Ok(())
}
@ -156,7 +160,7 @@ impl User {
song_id: i32,
like: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
) -> BackendResult<()> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
@ -169,7 +173,8 @@ impl User {
song_likes::song_id.eq(song_id),
song_likes::user_id.eq(self.id),
))
.execute(conn)?;
.execute(conn)
.context("Error liking song")?;
// Remove dislike if it exists
diesel::delete(
@ -179,7 +184,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing dislike for song")?;
} else {
diesel::delete(
song_likes::table.filter(
@ -188,7 +194,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing like for song")?;
}
Ok(())
@ -200,7 +207,7 @@ impl User {
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
) -> BackendResult<bool> {
use crate::schema::song_likes;
let like = song_likes::table
@ -210,7 +217,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.optional()
.context("Error checking if song is liked")?
.is_some();
Ok(like)
@ -224,7 +232,7 @@ impl User {
song_id: i32,
dislike: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
) -> BackendResult<()> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
@ -237,7 +245,8 @@ impl User {
song_dislikes::song_id.eq(song_id),
song_dislikes::user_id.eq(self.id),
))
.execute(conn)?;
.execute(conn)
.context("Error disliking song")?;
// Remove like if it exists
diesel::delete(
@ -247,7 +256,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing like for song")?;
} else {
diesel::delete(
song_dislikes::table.filter(
@ -256,7 +266,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing dislike for song")?;
}
Ok(())
@ -268,7 +279,7 @@ impl User {
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
) -> BackendResult<bool> {
use crate::schema::song_dislikes;
let dislike = song_dislikes::table
@ -278,7 +289,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.optional()
.context("Error checking if song is disliked")?
.is_some();
Ok(dislike)

View File

@ -6,7 +6,6 @@ use crate::models::frontend;
use leptos::either::*;
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
#[component]
pub fn AlbumPage() -> impl IntoView {
@ -61,12 +60,7 @@ fn AlbumIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
message=format!("Album with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting Album"
error
/>
}),
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
@ -98,21 +92,14 @@ fn AlbumSongs(#[prop(into)] id: Signal<i32>) -> impl IntoView {
<Transition
fallback= move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p> })
.collect_view()
}
}
>
{move || songs.get().map(|songs| {
songs.map(|songs| {
{move || songs.get().map(|songs| {
match songs {
Ok(songs) => Either::Left(
view! { <SongList songs=songs /> }
})
})}
</ErrorBoundary>
),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::models::backend::Artist;
@ -65,12 +64,7 @@ fn ArtistIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
message=format!("Artist with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting Artist"
error
/>
}),
Err(error) => EitherOf3::C(error.to_component())
}
})}
</Transition>
@ -126,21 +120,16 @@ fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
{move || top_songs.get().map(|top_songs| {
match top_songs {
Ok(top_songs) => {
Either::Left(view! {
<SongListExtra songs=top_songs />
})
},
Err(error) => Either::Right(error.to_component()),
}
>
{move || top_songs.get().map(|top_songs| {
top_songs.map(|top_songs| {
view! { <SongListExtra songs=top_songs /> }
})
})}
</ErrorBoundary>
})}
</Transition>
}
}
@ -162,27 +151,20 @@ fn AlbumsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move || albums.get().map(|albums| {
albums.map(|albums| {
{move || albums.get().map(|albums| {
match albums {
Ok(albums) => Either::Left({
let tiles = albums.into_iter().map(|album| {
album.into()
album.into()
}).collect::<Vec<_>>();
view! {
<DashboardRow title="Albums" tiles />
}
})
})}
</ErrorBoundary>
}),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::components::dashboard_row::DashboardRow;
use crate::components::error::*;
@ -118,13 +117,7 @@ fn UserIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
},
Err(error) => {
show_details.set(false);
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting User"
error
/>
})
EitherOf3::C(error.to_component())
}
}
})}
@ -203,25 +196,18 @@ fn TopSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move ||
top_songs.get().map(|top_songs| {
top_songs.map(|top_songs| {
{move ||
top_songs.get().map(|top_songs| {
match top_songs {
Ok(top_songs) => Either::Left({
view! {
<SongListExtra songs=top_songs />
}
})
})
}
</ErrorBoundary>
}),
Err(err) => Either::Right(err.to_component()),
}
})
}
</Transition>
}
}
@ -248,25 +234,16 @@ fn RecentSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
{move ||
recent_songs.get().map(|recent_songs| {
match recent_songs {
Ok(recent_songs) => Either::Left(view! {
<SongList songs=recent_songs />
}),
Err(err) => Either::Right(err.to_component()),
}
}
>
{move ||
recent_songs.get().map(|recent_songs| {
recent_songs.map(|recent_songs| {
view! {
<SongList songs=recent_songs />
}
})
})
}
</ErrorBoundary>
})
}
</Transition>
}
}
@ -305,19 +282,10 @@ fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Loading />
}
>
<ErrorBoundary
fallback=|errors| view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move ||
top_artists.get().map(|top_artists| {
top_artists.map(|top_artists| {
{move ||
top_artists.get().map(|top_artists| {
match top_artists {
Ok(top_artists) => Either::Left({
let tiles = top_artists.into_iter().map(|artist| {
artist.into()
}).collect::<Vec<_>>();
@ -325,10 +293,16 @@ fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
view! {
<DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
}
})
})
}
</ErrorBoundary>
}),
Err(err) => Either::Right(
view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
{err.to_component()}
}
)
}
})
}
</Transition>
}
}

View File

@ -1,8 +1,8 @@
use crate::api::search::search;
use crate::components::dashboard_row::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use leptos::either::*;
use leptos::html::Input;
use leptos::prelude::*;
use leptos_router::hooks::query_signal;
@ -35,20 +35,10 @@ pub fn Search() -> impl IntoView {
<Suspense
fallback=|| view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| {
errors.get().into_iter().map(|(_id, error)| {
view! {
<Error<String>
message=error.to_string()
/>
}
}).collect::<Vec<_>>()
}
>
{move || {
search.get().map(|results| {
results.map(|(albums, artists, songs)| {
{move || {
search.get().map(|results| {
match results {
Ok((albums, artists, songs)) => Either::Right(
view! {
{
(albums.is_empty() && artists.is_empty() && songs.is_empty()).then(|| {
@ -86,10 +76,11 @@ pub fn Search() -> impl IntoView {
})
}
}
})
})
}}
</ErrorBoundary>
),
Err(err) => Either::Left(err.to_component()),
}
})
}}
</Suspense>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::api::songs;
use crate::api::songs::*;
@ -67,14 +66,7 @@ fn SongDetails(#[prop(into)] id: Signal<i32>) -> impl IntoView {
/>
})
},
Err(error) => {
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Fetching Song"
error
/>
})
}
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
@ -153,14 +145,7 @@ fn SongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
<p>{format!("Plays: {plays}")}</p>
})
},
Err(error) => {
Either::Right(view! {
<ServerError<NoCustomError>
title="Error fetching song plays"
error
/>
})
}
Err(error) => Either::Right(error.to_component())
}
})}
</Transition>
@ -183,12 +168,7 @@ fn MySongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
})
},
Err(error) => {
Either::Right(view! {
<ServerError<NoCustomError>
title="Error fetching my song plays"
error
/>
})
Either::Right(error.to_component())
}
}
})}

View File

@ -1,3 +1,4 @@
use crate::util::error::*;
use std::fs::File;
use symphonia::core::codecs::CodecType;
use symphonia::core::formats::FormatOptions;
@ -7,38 +8,44 @@ use symphonia::core::probe::Hint;
/// Extract the codec and duration of an audio file
/// This is combined into one function because the file object will be consumed
pub fn extract_metadata(file: File) -> Result<(CodecType, u64), Box<dyn std::error::Error>> {
pub fn extract_metadata(file: File) -> BackendResult<(CodecType, u64)> {
let source_stream = MediaSourceStream::new(Box::new(file), Default::default());
let hint = Hint::new();
let format_opts = FormatOptions::default();
let metadata_opts = MetadataOptions::default();
let probe = symphonia::default::get_probe().format(
&hint,
source_stream,
&format_opts,
&metadata_opts,
)?;
let probe = symphonia::default::get_probe()
.format(&hint, source_stream, &format_opts, &metadata_opts)
.map_err(|e| InputError::InvalidInput(format!("{e}")))?;
let reader = probe.format;
if reader.tracks().len() != 1 {
return Err(format!("Expected 1 track, found {}", reader.tracks().len()).into());
return Err(InputError::InvalidInput(format!(
"Expected 1 track, found {}",
reader.tracks().len()
))
.into());
}
let track = &reader.tracks()[0];
let time_base = track.codec_params.time_base.ok_or("Missing time base")?;
let time_base = track
.codec_params
.time_base
.ok_or(InputError::InvalidInput("Missing time base".into()))?;
let duration = track
.codec_params
.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.ok_or("Missing number of frames")?;
.ok_or(InputError::InvalidInput("Missing frame count".to_string()))?;
let duration = duration
.checked_mul(time_base.numer as u64)
.and_then(|v| v.checked_div(time_base.denom as u64))
.ok_or("Overflow while computing duration")?;
.ok_or(BackendError::InternalError(
"Duration calculation overflowed",
))?;
Ok((track.codec_params.codec, duration))
}

View File

@ -1,14 +1,10 @@
use crate::util::error::*;
use multer::Field;
use server_fn::{error::NoCustomError, ServerFnError};
/// Extract the text from a multipart field
pub async fn extract_field(field: Field<'static>) -> Result<String, ServerFnError> {
let field = match field.text().await {
Ok(field) => field,
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading field: {e}"
)))?,
};
Ok(field)
pub async fn extract_field(field: Field<'static>) -> BackendResult<String> {
field
.text()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")).into())
}

View File

@ -6,6 +6,7 @@ use crate::api::playlists::get_playlists;
use crate::models::backend;
use crate::models::backend::User;
use crate::models::frontend::PlayStatus;
use crate::util::error::*;
/// Global front-end state
/// Contains anything frequently needed across multiple components
@ -22,7 +23,7 @@ pub struct GlobalState {
pub play_status: RwSignal<PlayStatus>,
/// A resource that fetches the playlists
pub playlists: Resource<Result<Vec<backend::Playlist>, ServerFnError>>,
pub playlists: Resource<BackendResult<Vec<backend::Playlist>>>,
}
impl GlobalState {
@ -60,7 +61,7 @@ impl GlobalState {
expect_context::<Self>().play_status
}
pub fn playlists() -> Resource<Result<Vec<backend::Playlist>, ServerFnError>> {
pub fn playlists() -> Resource<BackendResult<Vec<backend::Playlist>>> {
expect_context::<Self>().playlists
}
}