Run rustfmt
Some checks failed
Push Workflows / docs (push) Successful in 1m35s
Push Workflows / rustfmt (push) Successful in 11s
Push Workflows / clippy (push) Failing after 58s
Push Workflows / leptos-test (push) Successful in 3m4s
Push Workflows / test (push) Successful in 3m22s
Push Workflows / build (push) Successful in 4m43s
Push Workflows / docker-build (push) Failing after 14m42s
Push Workflows / nix-build (push) Successful in 17m22s

This commit is contained in:
2025-04-29 22:27:24 +00:00
parent 5fb84bd29e
commit 297c22d832
56 changed files with 3390 additions and 2781 deletions

View File

@ -1,141 +1,181 @@
use leptos::prelude::*;
use crate::models::frontend;
use leptos::prelude::*;
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;
}
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")]
pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError> {
use crate::models::backend::Album;
use crate::schema::*;
use crate::models::backend::Album;
use crate::schema::*;
let db_con = &mut get_db_conn();
let db_con = &mut get_db_conn();
let album = albums::table
.find(id)
.first::<Album>(db_con)
.optional()
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {e}")))?;
let album = albums::table
.find(id)
.first::<Album>(db_con)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {e}"))
})?;
let Some(album) = album else { return Ok(None) };
let Some(album) = album else { return Ok(None) };
let artists: Vec<backend::Artist> = album_artists::table
.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)?;
let artists: Vec<backend::Artist> = album_artists::table
.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)?;
let img = album.image_path.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
let img = album
.image_path
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
let album = frontend::Album {
id: album.id.unwrap(),
title: album.title,
artists,
release_date: album.release_date,
image_path: img
};
let album = frontend::Album {
id: album.id.unwrap(),
title: album.title,
artists,
release_date: album.release_date,
image_path: img,
};
Ok(Some(album))
Ok(Some(album))
}
#[server(endpoint = "album/get_songs")]
pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
use std::collections::HashMap;
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
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?;
let db_con = &mut get_db_conn();
let song_list = if let Some(user) = user {
let user_id = user.id.unwrap();
let song_list: Vec<(backend::Album, Option<backend::Song>, Option<backend::Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(user_id))))
.left_join(song_dislikes::table.on(songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_id))))
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable()
))
.order(songs::track.asc())
.load(db_con)?;
song_list
} else {
let song_list: Vec<(backend::Album, Option<backend::Song>, Option<backend::Artist>)> =
albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable()
))
.order(songs::track.asc())
.load(db_con)?;
let db_con = &mut get_db_conn();
let song_list: Vec<(backend::Album, Option<backend::Song>, Option<backend::Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
song_list.into_iter().map( |(album, song, artist)| (album, song, artist, None, None) ).collect();
song_list
};
let song_list = if let Some(user) = user {
let user_id = user.id.unwrap();
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
song_list
} else {
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
)> = albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
let mut album_songs: HashMap<i32, frontend::Song> = HashMap::with_capacity(song_list.len());
for (album, song, artist, like, dislike) in song_list {
if let Some(song) = song {
if let Some(stored_songdata) = album_songs.get_mut(&song.id.unwrap()) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album.image_path.clone().unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
let songdata = frontend::Song {
id: song.id.unwrap(),
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album: Some(album),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
album_songs.insert(song.id.unwrap(), songdata);
}
}
}
// Sort the songs by date
let mut songs: Vec<frontend::Song> = album_songs.into_values().collect();
songs.sort_by(|a, b| a.track.cmp(&b.track));
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = song_list
.into_iter()
.map(|(album, song, artist)| (album, song, artist, None, None))
.collect();
song_list
};
Ok(songs)
let mut album_songs: HashMap<i32, frontend::Song> = HashMap::with_capacity(song_list.len());
for (album, song, artist, like, dislike) in song_list {
if let Some(song) = song {
if let Some(stored_songdata) = album_songs.get_mut(&song.id.unwrap()) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album
.image_path
.clone()
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id.unwrap(),
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album: Some(album),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
album_songs.insert(song.id.unwrap(), songdata);
}
}
}
// Sort the songs by date
let mut songs: Vec<frontend::Song> = album_songs.into_values().collect();
songs.sort_by(|a, b| a.track.cmp(&b.track));
Ok(songs)
}

View File

@ -11,46 +11,54 @@ cfg_if! {
}
/// Add an album to the database
///
///
/// # Arguments
///
///
/// * `album_title` - The name of the artist to add
/// * `release_data` - The release date of the album (Optional)
/// * `image_path` - The path to the album's image file (Optional)
///
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
///
#[server(endpoint = "albums/add-album")]
pub async fn add_album(album_title: String, release_date: Option<String>, image_path: Option<String>) -> Result<(), ServerFnError> {
use crate::schema::albums::{self};
pub async fn add_album(
album_title: String,
release_date: Option<String>,
image_path: Option<String>,
) -> Result<(), ServerFnError> {
use crate::models::backend::Album;
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()))
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(),
))
}
},
None => None
None => None,
};
let image_path_arg = image_path.filter(|image_path| !image_path.is_empty());
let new_album = Album {
id: None,
title: album_title,
release_date: parsed_release_date,
image_path: image_path_arg
image_path: image_path_arg,
};
let db = &mut get_db_conn();
diesel::insert_into(albums::table)
.values(&new_album)
.execute(db)
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {e}")))?;
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {e}"))
})?;
Ok(())
}

View File

@ -2,8 +2,8 @@ use leptos::prelude::*;
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::models::backend::Artist;
use crate::models::frontend;
cfg_if! {
if #[cfg(feature = "ssr")] {
@ -16,14 +16,14 @@ cfg_if! {
}
/// Add an artist to the database
///
///
/// # Arguments
///
///
/// * `artist_name` - The name of the artist to add
///
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
///
#[server(endpoint = "artists/add-artist")]
pub async fn add_artist(artist_name: String) -> Result<(), ServerFnError> {
use crate::schema::artists::dsl::*;
@ -38,8 +38,10 @@ 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}")))?;
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding artist: {e}"))
})?;
Ok(())
}
@ -53,107 +55,140 @@ pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFn
.filter(id.eq(artist_id))
.first::<Artist>(db)
.optional()
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {e}")))?;
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {e}"))
})?;
Ok(artist)
}
#[server(endpoint = "artists/top_songs")]
pub async fn top_songs_by_artist(artist_id: i32, limit: Option<i64>) -> Result<Vec<(frontend::Song, i64)>, ServerFnError> {
use crate::models::backend::Song;
pub async fn top_songs_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<(frontend::Song, i64)>, ServerFnError> {
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.unwrap();
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::ServerError::<NoCustomError>(format!("Error getting user: {e}"))
})?
.id
.unwrap();
let db = &mut get_db_conn();
let song_play_counts: Vec<(i32, i64)> =
if let Some(limit) = limit {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.limit(limit)
.load(db)?
} else {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.load(db)?
};
let song_play_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.limit(limit)
.load(db)?
} else {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.load(db)?
};
let song_play_counts: HashMap<i32, i64> = song_play_counts.into_iter().collect();
let top_song_ids: Vec<i32> = song_play_counts.keys().copied().collect();
let top_songs: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
= songs::table
let top_songs: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.filter(songs::id.eq_any(top_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_id))))
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db)?;
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db)?;
let mut top_songs_map: HashMap<i32, (frontend::Song, i64)> = HashMap::with_capacity(top_songs.len());
let mut top_songs_map: HashMap<i32, (frontend::Song, i64)> =
HashMap::with_capacity(top_songs.len());
for (song, album, artist, like, dislike) in top_songs {
let song_id = song.id
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in database".to_string()))?;
for (song, album, artist, like, dislike) in top_songs {
let song_id = song.id.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in database".to_string(),
))?;
if let Some((stored_songdata, _)) = top_songs_map.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
if let Some((stored_songdata, _)) = top_songs_map.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album.as_ref().and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
let plays = song_play_counts.get(&song_id)
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in history counts".to_string()))?;
let plays = song_play_counts
.get(&song_id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
))?;
top_songs_map.insert(song_id, (songdata, *plays));
}
}
top_songs_map.insert(song_id, (songdata, *plays));
}
}
let mut top_songs: Vec<(frontend::Song, i64)> = top_songs_map.into_values().collect();
top_songs.sort_by(|(_, plays1), (_, plays2)| plays2.cmp(plays1));
@ -161,7 +196,10 @@ pub async fn top_songs_by_artist(artist_id: i32, limit: Option<i64>) -> Result<V
}
#[server(endpoint = "artists/albums")]
pub async fn albums_by_artist(artist_id: i32, limit: Option<i64>) -> Result<Vec<frontend::Album>, ServerFnError> {
pub async fn albums_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<frontend::Album>, ServerFnError> {
use crate::schema::*;
let db = &mut get_db_conn();
@ -177,18 +215,23 @@ pub async fn albums_by_artist(artist_id: i32, limit: Option<i64>) -> Result<Vec<
} else {
album_ids.into_boxed()
};
let mut albums_map: HashMap<i32, frontend::Album> = HashMap::new();
let album_artists: Vec<(Album, Artist)> = albums::table
.filter(albums::id.eq_any(album_ids))
.inner_join(album_artists::table.inner_join(artists::table).on(albums::id.eq(album_artists::album_id)))
.inner_join(
album_artists::table
.inner_join(artists::table)
.on(albums::id.eq(album_artists::album_id)),
)
.select((albums::all_columns, artists::all_columns))
.load(db)?;
for (album, artist) in album_artists {
let album_id = album.id
.ok_or(ServerFnError::ServerError::<NoCustomError>("Album id not found in database".to_string()))?;
let album_id = album.id.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Album id not found in database".to_string(),
))?;
if let Some(stored_album) = albums_map.get_mut(&album_id) {
stored_album.artists.push(artist);
@ -198,7 +241,9 @@ pub async fn albums_by_artist(artist_id: i32, limit: Option<i64>) -> Result<Vec<
title: album.title,
artists: vec![artist],
release_date: album.release_date,
image_path: album.image_path.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
image_path: album
.image_path
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
};
albums_map.insert(album_id, albumdata);

View File

@ -3,59 +3,62 @@ use leptos::prelude::*;
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;
}
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use leptos_axum::extract;
use axum_login::AuthSession;
use crate::util::auth_backend::AuthBackend;
}
}
use crate::models::backend::User;
use crate::api::users::UserCredentials;
use crate::models::backend::User;
/// 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")]
pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
// 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()));
}
// 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(),
));
}
use crate::api::users::create_user;
use crate::api::users::create_user;
// Ensure the user has no id, and is not a self-proclaimed admin
let new_user = User {
id: None,
admin: false,
..new_user
};
// Ensure the user has no id, and is not a self-proclaimed admin
let new_user = User {
id: None,
admin: false,
..new_user
};
create_user(&new_user).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}")))?;
create_user(&new_user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
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.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
password: new_user.password.clone().unwrap()
};
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
password: new_user.password.clone().unwrap(),
};
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}")))
}
}
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}"
))),
}
}
/// Log a user in
@ -63,47 +66,53 @@ pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
/// Returns a Result with a boolean indicating if the login was successful
#[server(endpoint = "login")]
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
use crate::api::users::validate_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.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let user = validate_user(credentials).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {e}")))?;
let user = validate_user(credentials).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {e}"))
})?;
if let Some(mut user) = user {
auth_session.login(&user).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}")))?;
if let Some(mut user) = user {
auth_session.login(&user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
})?;
user.password = None;
Ok(Some(user))
} else {
Ok(None)
}
user.password = None;
Ok(Some(user))
} else {
Ok(None)
}
}
/// Log a user out
/// Returns a Result with the error message if the user could not be logged out
#[server(endpoint = "logout")]
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}")))?;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
auth_session.logout().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}")))?;
auth_session.logout().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
leptos_axum::redirect("/login");
Ok(())
leptos_axum::redirect("/login");
Ok(())
}
/// Check if a user is logged in
/// Returns a Result with a boolean indicating if the user is logged in
#[server(endpoint = "check_auth")]
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}")))?;
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
Ok(auth_session.user.is_some())
Ok(auth_session.user.is_some())
}
/// Require that a user is logged in
@ -121,13 +130,15 @@ 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()))
}
})
check_auth().await.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
}
})
}
/// Get the current logged-in user
@ -147,33 +158,40 @@ 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}")))?;
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
auth_session.user.ok_or(ServerFnError::<NoCustomError>::ServerError("User not logged in".to_string()))
auth_session
.user
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"User not logged in".to_string(),
))
}
#[server(endpoint = "get_logged_in_user")]
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}")))?;
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let user = auth_session.user.map(|mut user| {
user.password = None;
user
});
let user = auth_session.user.map(|mut user| {
user.password = None;
user
});
Ok(user)
Ok(user)
}
/// 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")]
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}")))?;
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
}
/// Require that a user is logged in and an admin
@ -191,11 +209,13 @@ pub async fn check_admin() -> Result<bool, ServerFnError> {
/// ```
#[cfg(feature = "ssr")]
pub async fn require_admin() -> Result<(), ServerFnError> {
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError("Unauthorized".to_string()))
}
})
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
}
})
}

View File

@ -1,44 +1,49 @@
use chrono::NaiveDateTime;
use leptos::prelude::*;
use crate::models::backend::HistoryEntry;
use crate::models::backend::Song;
use chrono::NaiveDateTime;
use leptos::prelude::*;
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;
}
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::api::auth::get_user;
}
}
/// Get the history of the current user.
#[server(endpoint = "history/get")]
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, ServerFnError> {
let user = get_user().await?;
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}")))?;
Ok(history)
let user = get_user().await?;
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}"))
})?;
Ok(history)
}
/// Get the listen dates and songs of the current user.
#[server(endpoint = "history/get_songs")]
pub async fn get_history_songs(limit: Option<i64>) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> {
let user = get_user().await?;
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}")))?;
Ok(songs)
pub async fn get_history_songs(
limit: Option<i64>,
) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> {
let user = get_user().await?;
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}"))
})?;
Ok(songs)
}
/// Add a song to the history of the current user.
#[server(endpoint = "history/add")]
pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> {
let user = get_user().await?;
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}")))?;
Ok(())
let user = get_user().await?;
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}"))
})?;
Ok(())
}

View File

@ -1,10 +1,10 @@
pub mod artists;
pub mod album;
pub mod albums;
pub mod artists;
pub mod auth;
pub mod history;
pub mod profile;
pub mod songs;
pub mod album;
pub mod search;
pub mod songs;
pub mod upload;
pub mod auth;
pub mod users;

View File

@ -7,56 +7,68 @@ use crate::models::frontend;
use chrono::NaiveDateTime;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use server_fn::error::NoCustomError;
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::*;
use diesel::dsl::count;
use crate::models::backend::{Album, Artist, Song, HistoryEntry};
use crate::schema::*;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use diesel::dsl::count;
use crate::models::backend::{Album, Artist, Song, HistoryEntry};
use crate::schema::*;
use std::collections::HashMap;
}
use std::collections::HashMap;
}
}
/// 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> {
// 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();
// 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()))?;
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()))?;
if field.name() != Some("picture") {
return Err(ServerFnError::ServerError("Field name is not 'picture'".to_string()));
}
if field.name() != Some("picture") {
return Err(ServerFnError::ServerError(
"Field name is not 'picture'".to_string(),
));
}
// Get user id from session
let user = get_user().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}")))?;
// Get user id from session
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user_id = user.id.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("User has no id".to_string()))?;
let user_id = user
.id
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("User has no id".to_string()))?;
// Read the image, and convert it to webp
use image_convert::{to_webp, WEBPConfig, ImageResource};
// 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| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field bytes: {e}"))
})?;
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 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 profile_picture_path = format!("assets/images/profile/{user_id}.webp");
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}")))?;
let profile_picture_path = format!("assets/images/profile/{user_id}.webp");
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}"))
})?;
Ok(())
Ok(())
}
/// Get a user's recent songs listened to
@ -65,92 +77,121 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
/// Returns a list of tuples with the date the song was listened to
/// and the song data, sorted by date (most recent first).
#[server(endpoint = "/profile/recent_songs")]
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.unwrap();
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
.unwrap();
let mut db_con = get_db_conn();
let mut db_con = get_db_conn();
// Create an alias for the table so it can be referenced twice in the query
let history2 = diesel::alias!(song_history as history2);
// Create an alias for the table so it can be referenced twice in the query
let history2 = diesel::alias!(song_history as history2);
// Get the ids of the most recent songs listened to
let history_ids = history2
.filter(history2.fields(song_history::user_id).eq(for_user_id))
.order(history2.fields(song_history::date).desc())
.select(history2.fields(song_history::id));
// Get the ids of the most recent songs listened to
let history_ids = history2
.filter(history2.fields(song_history::user_id).eq(for_user_id))
.order(history2.fields(song_history::date).desc())
.select(history2.fields(song_history::id));
let history_ids = if let Some(limit) = limit {
history_ids.limit(limit).into_boxed()
} else {
history_ids.into_boxed()
};
let history_ids = if let Some(limit) = limit {
history_ids.limit(limit).into_boxed()
} else {
history_ids.into_boxed()
};
// Take the history ids and get the song data for them
let history: Vec<(HistoryEntry, Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
= song_history::table
.filter(song_history::id.eq_any(history_ids))
.inner_join(songs::table)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(viewing_user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(viewing_user_id))))
.select((
song_history::all_columns,
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Take the history ids and get the song data for them
let history: Vec<(
HistoryEntry,
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = song_history::table
.filter(song_history::id.eq_any(history_ids))
.inner_join(songs::table)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(viewing_user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(viewing_user_id))),
)
.select((
song_history::all_columns,
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Process the history data into a map of song ids to song data
let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
// Process the history data into a map of song ids to song data
let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
for (history, song, album, artist, like, dislike) in history {
let song_id = history.song_id;
for (history, song, album, artist, like, dislike) in history {
let song_id = history.song_id;
if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album.as_ref().and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
history_songs.insert(song_id, (history.date, songdata));
}
}
history_songs.insert(song_id, (history.date, songdata));
}
}
// Sort the songs by date
let mut history_songs: Vec<(NaiveDateTime, frontend::Song)> = history_songs.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs)
// Sort the songs by date
let mut history_songs: Vec<(NaiveDateTime, frontend::Song)> =
history_songs.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs)
}
/// Get a user's top songs by play count from a date range
@ -158,103 +199,135 @@ pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(N
/// If not provided, all songs listened to in the date range are returned.
/// Returns a list of tuples with the play count and the song data, sorted by play count (most played first).
#[server(endpoint = "/profile/top_songs")]
pub async fn top_songs(for_user_id: i32, 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.unwrap();
pub async fn top_songs(
for_user_id: i32,
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
.unwrap();
let mut db_con = get_db_conn();
let mut db_con = get_db_conn();
// Get the play count and ids of the songs listened to in the date range
let history_counts: Vec<(i32, i64)> =
if let Some(limit) = limit {
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)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_con)?
} 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)?
};
// Get the play count and ids of the songs listened to in the date range
let history_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
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)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_con)?
} 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)?
};
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
let history_song_ids = history_counts.keys().copied().collect::<Vec<i32>>();
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
let history_song_ids = history_counts.keys().copied().collect::<Vec<i32>>();
// Get the song data for the songs listened to in the date range
let history_songs: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
= songs::table
.filter(songs::id.eq_any(history_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(viewing_user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(viewing_user_id))))
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Get the song data for the songs listened to in the date range
let history_songs: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.filter(songs::id.eq_any(history_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(viewing_user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(viewing_user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Process the history data into a map of song ids to song data
let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> = HashMap::with_capacity(history_counts.len());
// Process the history data into a map of song ids to song data
let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> =
HashMap::with_capacity(history_counts.len());
for (song, album, artist, like, dislike) in history_songs {
let song_id = song.id
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in database".to_string()))?;
for (song, album, artist, like, dislike) in history_songs {
let song_id = song.id.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in database".to_string(),
))?;
if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album.as_ref().and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date.unwrap(),
};
let plays = history_counts.get(&song_id)
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in history counts".to_string()))?;
let plays = history_counts
.get(&song_id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
))?;
history_songs_map.insert(song_id, (*plays, songdata));
}
}
history_songs_map.insert(song_id, (*plays, songdata));
}
}
// Sort the songs by play count
let mut history_songs: Vec<(i64, frontend::Song)> = history_songs_map.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs)
// Sort the songs by play count
let mut history_songs: Vec<(i64, frontend::Song)> = history_songs_map.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs)
}
/// Get a user's top artists by play count from a date range
@ -262,42 +335,50 @@ pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: Na
/// If not provided, all artists listened to in the date range are returned.
/// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first).
#[server(endpoint = "/profile/top_artists")]
pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>)
-> Result<Vec<(i64, frontend::Artist)>, ServerFnError>
{
let mut db_con = get_db_conn();
pub async fn top_artists(
for_user_id: i32,
start_date: NaiveDateTime,
end_date: NaiveDateTime,
limit: Option<i64>,
) -> Result<Vec<(i64, frontend::Artist)>, ServerFnError> {
let mut db_con = get_db_conn();
let artist_counts: Vec<(i64, Artist)> =
if let Some(limit) = limit {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.limit(limit)
.load(&mut db_con)?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.load(&mut db_con)?
};
let artist_counts: Vec<(i64, Artist)> = if let Some(limit) = limit {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.limit(limit)
.load(&mut db_con)?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.load(&mut db_con)?
};
let artist_data: Vec<(i64, frontend::Artist)> = artist_counts.into_iter().map(|(plays, artist)| {
(plays, frontend::Artist {
id: artist.id.unwrap(),
name: artist.name,
image_path: format!("/assets/images/artist/{}.webp", artist.id.unwrap()),
})
}).collect();
let artist_data: Vec<(i64, frontend::Artist)> = artist_counts
.into_iter()
.map(|(plays, artist)| {
(
plays,
frontend::Artist {
id: artist.id.unwrap(),
name: artist.name,
image_path: format!("/assets/images/artist/{}.webp", artist.id.unwrap()),
},
)
})
.collect();
Ok(artist_data)
Ok(artist_data)
}

View File

@ -1,109 +1,112 @@
use crate::models::backend::{Album, Artist, Song};
use leptos::prelude::*;
use crate::models::backend::{Artist, Album, Song};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::sql_types::*;
use diesel::*;
use diesel::pg::Pg;
use diesel::expression::AsExpression;
use diesel::sql_types::*;
use diesel::*;
use diesel::pg::Pg;
use diesel::expression::AsExpression;
use crate::util::database::get_db_conn;
use crate::util::database::get_db_conn;
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
diesel::infix_operator!(Similarity, " % ", backend: Pg);
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
diesel::infix_operator!(Similarity, " % ", backend: Pg);
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
// Create functions to make use of the operators in queries
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
// Create functions to make use of the operators in queries
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
Distance::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
Distance::new(left.as_expression(), right.as_expression())
}
}
}
/// Search for albums by title
///
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
/// `limit` - The maximum number of results to return
///
///
/// # Returns
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
#[server(endpoint = "search_albums")]
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
use crate::schema::albums::dsl::*;
use crate::schema::albums::dsl::*;
Ok(albums
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
Ok(albums
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for artists by name
///
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
/// `limit` - The maximum number of results to return
///
///
/// # Returns
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
#[server(endpoint = "search_artists")]
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
use crate::schema::artists::dsl::*;
use crate::schema::artists::dsl::*;
Ok(artists
.filter(trgm_similar(name, query.clone()))
.order_by(trgm_distance(name, query))
.limit(limit)
.load(&mut get_db_conn())?)
Ok(artists
.filter(trgm_similar(name, query.clone()))
.order_by(trgm_distance(name, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs by title
///
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
/// `limit` - The maximum number of results to return
///
///
/// # Returns
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
#[server(endpoint = "search_songs")]
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
use crate::schema::songs::dsl::*;
use crate::schema::songs::dsl::*;
Ok(songs
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
Ok(songs
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs, albums, and artists by title or name
///
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the
/// song titles, album titles, and artist names
/// `limit` - The maximum number of results to return for each type
///
///
/// # Returns
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
#[server(endpoint = "search")]
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query.clone(), limit);
pub async fn search(
query: String,
limit: i64,
) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query.clone(), limit);
use tokio::join;
use tokio::join;
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
}

View File

@ -5,145 +5,190 @@ use cfg_if::cfg_if;
use crate::models::frontend;
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};
use diesel::prelude::*;
}
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};
use diesel::prelude::*;
}
}
/// Like or unlike a song
#[server(endpoint = "songs/set_like")]
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}")))?;
let db_con = &mut get_db_conn();
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
user.set_like_song(song_id, like, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error liking song: {e}")))
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}")))
}
/// Dislike or remove dislike from a song
#[server(endpoint = "songs/set_dislike")]
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}")))?;
let db_con = &mut get_db_conn();
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
user.set_dislike_song(song_id, dislike, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error disliking song: {e}")))
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}"))
})
}
/// Get the like and dislike status of a song
#[server(endpoint = "songs/get_like_dislike")]
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}")))?;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let db_con = &mut get_db_conn();
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
// 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.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}"))
})?;
Ok((like, dislike))
Ok((like, dislike))
}
#[server(endpoint = "songs/get")]
pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, ServerFnError> {
use crate::schema::*;
use crate::schema::*;
let user_id: i32 = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error getting user: {e}")))?.id.unwrap();
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id
.unwrap();
let db_con = &mut get_db_conn();
let db_con = &mut get_db_conn();
let song_parts: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
= songs::table
.find(song_id)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_id))))
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db_con)?;
let song_parts: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.find(song_id)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db_con)?;
let song = song_parts.first().cloned();
let artists = song_parts.into_iter().filter_map(|(_, _, artist, _, _)| artist).collect::<Vec<_>>();
let song = song_parts.first().cloned();
let artists = song_parts
.into_iter()
.filter_map(|(_, _, artist, _, _)| artist)
.collect::<Vec<_>>();
match song {
Some((song, album, _artist, like, dislike)) => {
// Use song image path, or fall back to album image path, or fall back to placeholder
let image_path = song.image_path.clone().unwrap_or_else(|| {
album.as_ref().and_then(|album| album.image_path.clone()).unwrap_or(
"/assets/images/placeholders/MusicPlaceholder.svg".to_string()
)
});
match song {
Some((song, album, _artist, like, dislike)) => {
// Use song image path, or fall back to album image path, or fall back to placeholder
let image_path = song.image_path.clone().unwrap_or_else(|| {
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())
});
Ok(Some(frontend::Song {
id: song.id.unwrap(),
title: song.title.clone(),
artists,
album: album.clone(),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path.clone(),
image_path,
like_dislike: Some((like.is_some(), dislike.is_some())),
added_date: song.added_date.unwrap(),
}))
},
None => Ok(None)
}
Ok(Some(frontend::Song {
id: song.id.unwrap(),
title: song.title.clone(),
artists,
album: album.clone(),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path.clone(),
image_path,
like_dislike: Some((like.is_some(), dislike.is_some())),
added_date: song.added_date.unwrap(),
}))
}
None => Ok(None),
}
}
#[server(endpoint = "songs/plays")]
pub async fn get_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
use crate::schema::*;
use crate::schema::*;
let db_con = &mut get_db_conn();
let db_con = &mut get_db_conn();
let plays = song_history::table
.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}")))?;
let plays = song_history::table
.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}"))
})?;
Ok(plays)
Ok(plays)
}
#[server(endpoint = "songs/my-plays")]
pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
use crate::schema::*;
use crate::schema::*;
let user_id: i32 = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error getting user: {e}")))?.id.unwrap();
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id
.unwrap();
let db_con = &mut get_db_conn();
let db_con = &mut get_db_conn();
let plays = song_history::table
.filter(song_history::song_id.eq(song_id).and(song_history::user_id.eq(user_id)))
.count()
.get_result::<i64>(db_con)
.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error getting song plays: {e}")))?;
let plays = song_history::table
.filter(
song_history::song_id
.eq(song_id)
.and(song_history::user_id.eq(user_id)),
)
.count()
.get_result::<i64>(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
})?;
Ok(plays)
Ok(plays)
}

View File

@ -4,25 +4,27 @@ use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use multer::Field;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use log::*;
use server_fn::error::NoCustomError;
use chrono::NaiveDate;
}
if #[cfg(feature = "ssr")] {
use multer::Field;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use log::*;
use server_fn::error::NoCustomError;
use chrono::NaiveDate;
}
}
/// Extract the text from a multipart field
#[cfg(feature = "ssr")]
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}")))?,
};
let field = match field.text().await {
Ok(field) => field,
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading field: {e}"
)))?,
};
Ok(field)
Ok(field)
}
/// Validate the artist ids in a multipart field
@ -30,265 +32,324 @@ async fn extract_field(field: Field<'static>) -> Result<String, ServerFnError> {
#[cfg(feature = "ssr")]
async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, ServerFnError> {
use crate::models::backend::Artist;
use diesel::result::Error::NotFound;
use diesel::result::Error::NotFound;
// Extract the artist id from the field
match artist_ids.text().await {
Ok(artist_ids) => {
let artist_ids = artist_ids.trim_end_matches(',').split(',');
// Extract the artist id from the field
match artist_ids.text().await {
Ok(artist_ids) => {
let artist_ids = artist_ids.trim_end_matches(',').split(',');
artist_ids.filter(|artist_id| !artist_id.is_empty()).map(|artist_id| {
// Parse the artist id as an integer
if let Ok(artist_id) = artist_id.parse::<i32>() {
// Check if the artist exists
let db_con = &mut get_db_conn();
let artist = crate::schema::artists::dsl::artists.find(artist_id).first::<Artist>(db_con);
artist_ids
.filter(|artist_id| !artist_id.is_empty())
.map(|artist_id| {
// Parse the artist id as an integer
if let Ok(artist_id) = artist_id.parse::<i32>() {
// Check if the artist exists
let db_con = &mut get_db_conn();
let artist = crate::schema::artists::dsl::artists
.find(artist_id)
.first::<Artist>(db_con);
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}"))),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError("Error parsing artist id".to_string()))
}
}).collect()
},
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}"
))),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing artist id".to_string(),
))
}
})
.collect()
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!("Error reading artist id: {e}"))),
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading artist id: {e}"
))),
}
}
/// 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> {
use crate::models::backend::Album;
use diesel::result::Error::NotFound;
use crate::models::backend::Album;
use diesel::result::Error::NotFound;
// Extract the album id from the field
match album_id.text().await {
Ok(album_id) => {
if album_id.is_empty() {
return Ok(None);
}
// Extract the album id from the field
match album_id.text().await {
Ok(album_id) => {
if album_id.is_empty() {
return Ok(None);
}
// Parse the album id as an integer
if let Ok(album_id) = album_id.parse::<i32>() {
// Check if the album exists
let db_con = &mut get_db_conn();
let album = crate::schema::albums::dsl::albums.find(album_id).first::<Album>(db_con);
// Parse the album id as an integer
if let Ok(album_id) = album_id.parse::<i32>() {
// Check if the album exists
let db_con = &mut get_db_conn();
let album = crate::schema::albums::dsl::albums
.find(album_id)
.first::<Album>(db_con);
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}"))),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError("Error parsing album id".to_string()))
}
},
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!("Error reading album id: {e}"))),
}
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}"
))),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing album id".to_string(),
))
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading album id: {e}"
))),
}
}
/// 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> {
match track_number.text().await {
Ok(track_number) => {
if track_number.is_empty() {
return Ok(None);
}
match track_number.text().await {
Ok(track_number) => {
if track_number.is_empty() {
return Ok(None);
}
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()))
} else {
Ok(Some(track_number))
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError("Error parsing track number".to_string()))
}
},
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!("Error reading track number: {e}")))?,
}
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(),
))
} else {
Ok(Some(track_number))
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing track number".to_string(),
))
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading track number: {e}"
)))?,
}
}
/// 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> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
return Ok(None);
}
async fn validate_release_date(
release_date: Field<'static>,
) -> Result<Option<NaiveDate>, ServerFnError> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
return Ok(None);
}
let release_date = NaiveDate::parse_from_str(release_date.trim(), "%Y-%m-%d");
let release_date = NaiveDate::parse_from_str(release_date.trim(), "%Y-%m-%d");
match release_date {
Ok(release_date) => Ok(Some(release_date)),
Err(_) => Err(ServerFnError::<NoCustomError>::ServerError("Invalid release date".to_string())),
}
},
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!("Error reading release date: {e}"))),
}
match release_date {
Ok(release_date) => Ok(Some(release_date)),
Err(_) => Err(ServerFnError::<NoCustomError>::ServerError(
"Invalid release date".to_string(),
)),
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading release date: {e}"
))),
}
}
/// Handle the file upload form
#[server(input = MultipartFormData, endpoint = "/upload")]
pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
// 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();
// 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 mut title = None;
let mut artist_ids = None;
let mut album_id = None;
let mut track = None;
let mut release_date = None;
let mut file_name = None;
let mut duration = None;
let mut title = None;
let mut artist_ids = None;
let mut album_id = None;
let mut track = None;
let mut release_date = None;
let mut file_name = None;
let mut duration = None;
// Fetch the fields from the form data
while let Ok(Some(mut field)) = data.next_field().await {
let name = field.name().unwrap_or_default().to_string();
// Fetch the fields from the form data
while let Ok(Some(mut field)) = data.next_field().await {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"title" => { title = Some(extract_field(field).await?); },
"artist_ids" => { artist_ids = Some(validate_artist_ids(field).await?); },
"album_id" => { album_id = Some(validate_album_id(field).await?); },
"track_number" => { track = Some(validate_track_number(field).await?); },
"release_date" => { release_date = Some(validate_release_date(field).await?); },
"file" => {
use symphonia::core::codecs::CODEC_TYPE_MP3;
use crate::util::audio::extract_metadata;
use std::fs::OpenOptions;
use std::io::{Seek, Write};
match name.as_str() {
"title" => {
title = Some(extract_field(field).await?);
}
"artist_ids" => {
artist_ids = Some(validate_artist_ids(field).await?);
}
"album_id" => {
album_id = Some(validate_album_id(field).await?);
}
"track_number" => {
track = Some(validate_track_number(field).await?);
}
"release_date" => {
release_date = Some(validate_release_date(field).await?);
}
"file" => {
use crate::util::audio::extract_metadata;
use std::fs::OpenOptions;
use std::io::{Seek, Write};
use symphonia::core::codecs::CODEC_TYPE_MP3;
// Some logging is done here where there is high potential for bugs / failures,
// or behavior that we may wish to change in the future
// Some logging is done here where there is high potential for bugs / failures,
// 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()))?;
// Create file name
let title = title
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Title field required 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();
let upload_path = format!("assets/audio/upload-{date_str}_{clean_title}.mp3");
file_name = Some(format!("upload-{date_str}_{clean_title}.mp3"));
let clean_title = title.replace(" ", "_").replace("/", "_");
let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
let upload_path = format!("assets/audio/upload-{date_str}_{clean_title}.mp3");
file_name = Some(format!("upload-{date_str}_{clean_title}.mp3"));
debug!("Saving uploaded file {}", upload_path);
debug!("Saving uploaded file {}", upload_path);
// Save file to disk
// Use these open options to create the file, write to it, then read from it
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(upload_path.clone())?;
// Save file to disk
// Use these open options to create the file, write to it, then read from it
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(upload_path.clone())?;
while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?;
}
while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?;
}
file.flush()?;
file.flush()?;
// Rewind the file so the duration can be measured
file.rewind()?;
// Rewind the file so the duration can be measured
file.rewind()?;
// 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)
})?;
// 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)
})?;
if file_codec != CODEC_TYPE_MP3 {
let msg = format!("Invalid uploaded audio file codec: {file_codec}");
warn!("{}", msg);
return Err(ServerFnError::<NoCustomError>::ServerError(msg));
}
if file_codec != CODEC_TYPE_MP3 {
let msg = format!("Invalid uploaded audio file codec: {file_codec}");
warn!("{}", msg);
return Err(ServerFnError::<NoCustomError>::ServerError(msg));
}
duration = Some(file_duration);
},
_ => {
warn!("Unknown file upload field: {}", name);
}
}
}
duration = Some(file_duration);
}
_ => {
warn!("Unknown file upload field: {}", name);
}
}
}
// Unwrap mandatory fields
let title = title.ok_or(ServerFnError::<NoCustomError>::ServerError("Missing 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}")))?;
// Unwrap mandatory fields
let title = title.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing 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 album_id = album_id.unwrap_or(None);
let track = track.unwrap_or(None);
let release_date = release_date.unwrap_or(None);
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("Album id and track number must both be present or both be absent".to_string()));
}
if album_id.is_some() != track.is_some() {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Album id and track number must both be present or both be absent".to_string(),
));
}
// Create the song
use crate::models::backend::Song;
let song = Song {
id: None,
title,
album_id,
track,
duration,
release_date,
storage_path: file_name,
image_path: None,
added_date: None, // Defaults to current date
};
// Create the song
use crate::models::backend::Song;
let song = Song {
id: None,
title,
album_id,
track,
duration,
release_date,
storage_path: file_name,
image_path: None,
added_date: None, // Defaults to current date
};
// Save the song to the database
let db_con = &mut get_db_conn();
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)
})?;
// Save the song to the database
let db_con = &mut get_db_conn();
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)
})?;
// Save the song's artists to the database
let song_id = song.id.ok_or_else(|| {
let msg = "Error saving song to database: song id not found after insertion".to_string();
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
// Save the song's artists to the database
let song_id = song.id.ok_or_else(|| {
let msg = "Error saving song to database: song id not found after insertion".to_string();
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
use crate::schema::song_artists;
use diesel::ExpressionMethods;
use crate::schema::song_artists;
use diesel::ExpressionMethods;
let artist_ids = artist_ids.into_iter().map(|artist_id| {
(song_artists::song_id.eq(song_id), song_artists::artist_id.eq(artist_id))
}).collect::<Vec<_>>();
let artist_ids = artist_ids
.into_iter()
.map(|artist_id| {
(
song_artists::song_id.eq(song_id),
song_artists::artist_id.eq(artist_id),
)
})
.collect::<Vec<_>>();
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)
})?;
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)
})?;
Ok(())
Ok(())
}

View File

@ -1,142 +1,183 @@
cfg_if::cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::get_db_conn;
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::get_db_conn;
use pbkdf2::{
password_hash::{
rand_core::OsRng,
PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error
},
Pbkdf2
};
}
use pbkdf2::{
password_hash::{
rand_core::OsRng,
PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error
},
Pbkdf2
};
}
}
use leptos::prelude::*;
use serde::{Serialize, Deserialize};
use crate::models::backend::User;
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCredentials {
pub username_or_email: String,
pub password: String
pub username_or_email: String,
pub password: String,
}
/// 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> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
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();
let user = users.filter(username.eq(username_or_email.clone())).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}")))?;
// 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();
let user = users
.filter(username.eq(username_or_email.clone()))
.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}"
))
})?;
Ok(user)
Ok(user)
}
/// 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> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
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}")))?;
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}"
))
})?;
Ok(user)
Ok(user)
}
/// 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: &User) -> Result<(), ServerFnError> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
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)))?;
let new_password =
new_user
.password
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
"No password provided for user {}",
new_user.username
)))?;
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()))?.to_string();
let new_user = User {
password: Some(password_hash),
..new_user.clone()
};
let db_con = &mut get_db_conn();
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())
})?
.to_string();
diesel::insert_into(users).values(&new_user).execute(db_con)
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}")))?;
let new_user = User {
password: Some(password_hash),
..new_user.clone()
};
Ok(())
let db_con = &mut get_db_conn();
diesel::insert_into(users)
.values(&new_user)
.execute(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
Ok(())
}
/// 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;
use leptos::server_fn::error::NoCustomError;
let db_user = find_user(credentials.username_or_email.clone()).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting user from database: {e}")))?;
let db_user = find_user(credentials.username_or_email.clone())
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
// If the user is not found, return None
let db_user = match db_user {
Some(user) => user,
None => return Ok(None)
};
// If the user is not found, return None
let db_user = match db_user {
Some(user) => 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(ServerFnError::<NoCustomError>::ServerError(format!(
"No password found for user {}",
db_user.username
)))?;
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| {
ServerFnError::<NoCustomError>::ServerError(format!("Error hashing supplied password: {e}"))
})?;
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
Ok(()) => {},
Err(Error::Password) => {
return Ok(None);
},
Err(e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(format!("Error verifying password: {e}")));
}
}
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
Ok(()) => {}
Err(Error::Password) => {
return Ok(None);
}
Err(e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error verifying password: {e}"
)));
}
}
Ok(Some(db_user))
Ok(Some(db_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")]
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
let mut user = find_user(username_or_email).await?;
let mut user = find_user(username_or_email).await?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
Ok(user)
Ok(user)
}
#[server(endpoint = "get_user_by_id")]
pub async fn get_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
let mut user = find_user_by_id(user_id).await?;
let mut user = find_user_by_id(user_id).await?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
Ok(user)
Ok(user)
}

View File

@ -1,20 +1,20 @@
use crate::components::playbar::PlayBar;
use crate::components::error_template::{AppError, ErrorTemplate};
use crate::components::playbar::CustomTitle;
use crate::components::playbar::PlayBar;
use crate::components::queue::Queue;
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::*;
use leptos_router::components::*;
use crate::pages::login::*;
use crate::pages::signup::*;
use crate::pages::profile::*;
use crate::pages::album::*;
use crate::pages::artist::*;
use crate::pages::song::*;
use crate::pages::search::*;
use crate::pages::dashboard::*;
use crate::components::error_template::{AppError, ErrorTemplate};
use crate::pages::login::*;
use crate::pages::profile::*;
use crate::pages::search::*;
use crate::pages::signup::*;
use crate::pages::song::*;
use crate::util::state::GlobalState;
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::components::*;
use leptos_router::*;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@ -82,15 +82,19 @@ pub fn App() -> impl IntoView {
}
}
use crate::components::sidebar::*;
use crate::components::personal::Personal;
use crate::components::upload::*;
use crate::components::add_artist::AddArtist;
use crate::components::add_album::AddAlbum;
use crate::components::add_artist::AddArtist;
use crate::components::personal::Personal;
use crate::components::sidebar::*;
use crate::components::upload::*;
/// Renders the home page of your application.
#[component]
fn HomePage(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
fn HomePage(
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
view! {
<section class="bg-black h-screen flex">
<Upload open=upload_open/>

View File

@ -1,8 +1,8 @@
use leptos::prelude::*;
use leptos::leptos_dom::log;
use leptos_icons::*;
use leptos::task::spawn_local;
use crate::api::albums::add_album;
use leptos::leptos_dom::log;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
#[component]
pub fn AddAlbumBtn(add_album_open: RwSignal<bool>) -> impl IntoView {
@ -22,9 +22,9 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
let image_path = RwSignal::new("".to_string());
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
ev.prevent_default();
open.set(false);
};
let on_add_album = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
@ -33,7 +33,8 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
let image_path_clone = Some(image_path.get());
spawn_local(async move {
let add_album_result = add_album(album_title_clone, release_date_clone, image_path_clone).await;
let add_album_result =
add_album(album_title_clone, release_date_clone, image_path_clone).await;
if let Err(err) = add_album_result {
log!("Error adding album: {:?}", err);
} else if let Ok(album) = add_album_result {
@ -54,32 +55,32 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
<div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
<form class="create-album-form" on:submit=on_add_album>
<div class="input-bx">
<input type="text" required class="text-input"
<input type="text" required class="text-input"
prop:value=album_title
on:input=move |ev: leptos::ev::Event| {
album_title.set(event_target_value(&ev));
}
}
/>
<span>Album Title</span>
</div>
<div class="release-date">
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date"
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date"
prop:value=release_date
on:input=move |ev: leptos::ev::Event| {
release_date.set(event_target_value(&ev));
}
/>
</div>
</div>
<div class="input-bx">
<input type="text" class="text-input"
<input type="text" class="text-input"
prop:value=image_path
on:input=move |ev: leptos::ev::Event| {
image_path.set(event_target_value(&ev));
}
}
/>
<span>Image Path</span>
</div>
@ -88,5 +89,4 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
</div>
</Show>
}
}
}

View File

@ -1,8 +1,8 @@
use leptos::prelude::*;
use leptos::leptos_dom::log;
use leptos_icons::*;
use leptos::task::spawn_local;
use crate::api::artists::add_artist;
use leptos::leptos_dom::log;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
#[component]
pub fn AddArtistBtn(add_artist_open: RwSignal<bool>) -> impl IntoView {
@ -20,9 +20,9 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
let artist_name = RwSignal::new("".to_string());
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
ev.prevent_default();
open.set(false);
};
let on_add_artist = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let artist_name_clone = artist_name.get();
@ -46,11 +46,11 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
<div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
<form class="create-artist-form" on:submit=on_add_artist>
<div class="input-bx">
<input type="text" name="title" required class="text-input"
<input type="text" name="title" required class="text-input"
prop:value=artist_name
on:input=move |ev: leptos::ev::Event| {
artist_name.set(event_target_value(&ev));
}
}
/>
<span>Artist Name</span>
</div>
@ -59,4 +59,4 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
</div>
</Show>
}
}
}

View File

@ -1,86 +1,89 @@
use crate::components::dashboard_tile::*;
use leptos::html::Ul;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos::text_prop::TextProp;
use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn};
use crate::components::dashboard_tile::*;
use leptos_icons::*;
use leptos_use::{use_element_size, use_scroll, UseElementSizeReturn, UseScrollReturn};
/// A row of dashboard tiles, with a title
#[component]
pub fn DashboardRow(
#[prop(into)] title: TextProp,
#[prop(default=vec![])] tiles: Vec<DashboardTile>,
#[prop(into)] title: TextProp,
#[prop(default=vec![])] tiles: Vec<DashboardTile>,
) -> impl IntoView {
let list_ref = NodeRef::<Ul>::new();
let list_ref = NodeRef::<Ul>::new();
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
let scroll_left = move |_| {
if let Some(scroll_element) = list_ref.get_untracked() {
let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos - client_width;
let scroll_left = move |_| {
if let Some(scroll_element) = list_ref.get_untracked() {
let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos - client_width;
if let Some(first_tile) = scroll_element.first_element_child() {
let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else {
warn!("Could not get first tile to scroll left");
// Fall back to scrolling by the client width if we can't get the tile width
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
}
} else {
warn!("Could not get scroll element to scroll left");
}
};
if let Some(first_tile) = scroll_element.first_element_child() {
let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else {
warn!("Could not get first tile to scroll left");
// Fall back to scrolling by the client width if we can't get the tile width
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
}
} else {
warn!("Could not get scroll element to scroll left");
}
};
let scroll_right = move |_| {
if let Some(scroll_element) = list_ref.get_untracked() {
let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos + client_width;
if let Some(first_tile) = scroll_element.first_element_child() {
let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos - (desired_pos % tile_width);
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else {
warn!("Could not get first tile to scroll right");
// Fall back to scrolling by the client width if we can't get the tile width
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
}
} else {
warn!("Could not get scroll element to scroll right");
}
};
let scroll_right = move |_| {
if let Some(scroll_element) = list_ref.get_untracked() {
let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos + client_width;
let UseElementSizeReturn { width: scroll_element_width, .. } = use_element_size(list_ref);
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
if let Some(first_tile) = scroll_element.first_element_child() {
let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos - (desired_pos % tile_width);
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else {
warn!("Could not get first tile to scroll right");
// Fall back to scrolling by the client width if we can't get the tile width
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
}
} else {
warn!("Could not get scroll element to scroll right");
}
};
let scroll_right_hidden = Signal::derive(move || {
if let Some(scroll_element) = list_ref.get() {
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
"visibility: hidden"
} else {
""
}
} else {
""
}
});
let UseElementSizeReturn {
width: scroll_element_width,
..
} = use_element_size(list_ref);
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
let scroll_left_hidden = Signal::derive(move || {
if scroll_x.get() <= 0.0 {
"visibility: hidden"
} else {
""
}
});
let scroll_right_hidden = Signal::derive(move || {
if let Some(scroll_element) = list_ref.get() {
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
"visibility: hidden"
} else {
""
}
} else {
""
}
});
view! {
let scroll_left_hidden = Signal::derive(move || {
if scroll_x.get() <= 0.0 {
"visibility: hidden"
} else {
""
}
});
view! {
<div>
<div class="flex">
<h2 class="text-xl font-bold">{move || title.get()}</h2>

View File

@ -3,12 +3,12 @@ use leptos::text_prop::TextProp;
#[slot]
pub struct DashboardTile {
#[prop(into)]
image_path: TextProp,
#[prop(into)]
title: TextProp,
#[prop(into)]
link: TextProp,
#[prop(into, optional)]
description: Option<TextProp>,
#[prop(into)]
image_path: TextProp,
#[prop(into)]
title: TextProp,
#[prop(into)]
link: TextProp,
#[prop(into, optional)]
description: Option<TextProp>,
}

View File

@ -5,42 +5,36 @@ use std::fmt::Display;
#[component]
pub fn ServerError<E: Display + 'static>(
#[prop(optional, into, default="An Error Occurred".into())]
title: TextProp,
#[prop(optional, into)]
message: TextProp,
#[prop(optional, into)]
error: Option<ServerFnError<E>>,
#[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
#[prop(optional, into)] message: TextProp,
#[prop(optional, into)] error: Option<ServerFnError<E>>,
) -> impl IntoView {
view!{
<div class="error-container">
<div class="error-header">
<Icon icon={icondata::BiErrorSolid} />
<h1>{move || title.get()}</h1>
</div>
<p>{move || message.get()}</p>
<p>{error.map(|error| format!("{error}"))}</p>
</div>
}
view! {
<div class="error-container">
<div class="error-header">
<Icon icon={icondata::BiErrorSolid} />
<h1>{move || title.get()}</h1>
</div>
<p>{move || message.get()}</p>
<p>{error.map(|error| format!("{error}"))}</p>
</div>
}
}
#[component]
pub fn Error<E: Display + 'static>(
#[prop(optional, into, default="An Error Occurred".into())]
title: TextProp,
#[prop(optional, into)]
message: TextProp,
#[prop(optional, into)]
error: Option<E>,
#[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
#[prop(optional, into)] message: TextProp,
#[prop(optional, into)] error: Option<E>,
) -> impl IntoView {
view! {
<div class="text-red-800">
<div class="grid grid-cols-[max-content_1fr] gap-1">
<Icon icon={icondata::BiErrorSolid} {..} class="self-center" />
<h1 class="self-center">{move || title.get()}</h1>
</div>
<p>{move || message.get()}</p>
<p>{error.map(|error| format!("{error}"))}</p>
</div>
}
view! {
<div class="text-red-800">
<div class="grid grid-cols-[max-content_1fr] gap-1">
<Icon icon={icondata::BiErrorSolid} {..} class="self-center" />
<h1 class="self-center">{move || title.get()}</h1>
</div>
<p>{move || message.get()}</p>
<p>{error.map(|error| format!("{error}"))}</p>
</div>
}
}

View File

@ -51,7 +51,7 @@ pub fn ErrorTemplate(
response.set_status(errors[0].status_code());
}
}
view! {
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
<For

View File

@ -3,24 +3,24 @@ use leptos::prelude::*;
/// A loading indicator
#[component]
pub fn Loading() -> impl IntoView {
let dots_style = "h-2 w-2 bg-accent rounded-full animate-pulse";
let dots_style = "h-2 w-2 bg-accent rounded-full animate-pulse";
view! {
<div class="flex space-x-1 justify-center items-center my-2">
<span class="sr-only">"Loading..."</span>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 0ms;" />
<div class=dots_style style="animation-duration: 900ms; animation-delay: 300ms"/>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 600ms;" />
</div>
}
view! {
<div class="flex space-x-1 justify-center items-center my-2">
<span class="sr-only">"Loading..."</span>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 0ms;" />
<div class=dots_style style="animation-duration: 900ms; animation-delay: 300ms"/>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 600ms;" />
</div>
}
}
/// A full page, centered loading indicator
#[component]
pub fn LoadingPage() -> impl IntoView {
view!{
<div class="loading-page">
<Loading />
</div>
}
view! {
<div class="loading-page">
<Loading />
</div>
}
}

View File

@ -1,6 +1,6 @@
use crate::components::upload_dropdown::*;
use leptos::prelude::*;
use leptos_icons::*;
use crate::components::upload_dropdown::*;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuEntry {
@ -31,10 +31,7 @@ impl MenuEntry {
}
pub const fn all() -> [MenuEntry; 2] {
[
MenuEntry::Dashboard,
MenuEntry::Search,
]
[MenuEntry::Dashboard, MenuEntry::Search]
}
}
@ -42,7 +39,7 @@ impl MenuEntry {
pub fn MenuItem(entry: MenuEntry, #[prop(into)] active: Signal<bool>) -> impl IntoView {
view! {
<a class="menu-btn" href={entry.path().to_string()}
style={move || if active() {"color: var(--color-menu-active);"} else {""}}
style={move || if active() {"color: var(--color-menu-active);"} else {""}}
>
<Icon height="1.7rem" width="1.7rem" icon={entry.icon()} {..} class="mr-2" />
<h2>{entry.title()}</h2>
@ -51,13 +48,19 @@ pub fn MenuItem(entry: MenuEntry, #[prop(into)] active: Signal<bool>) -> impl In
}
#[component]
pub fn Menu(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
pub fn Menu(
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
use leptos_router::hooks::use_location;
let location = use_location();
let active_entry = Signal::derive(move || {
let path = location.pathname.get();
MenuEntry::all().into_iter().find(|entry| entry.path() == path)
MenuEntry::all()
.into_iter()
.find(|entry| entry.path() == path)
});
let dropdown_open = RwSignal::new(false);

View File

@ -1,17 +1,17 @@
pub mod sidebar;
pub mod personal;
pub mod dashboard_tile;
pub mod dashboard_row;
pub mod upload;
pub mod upload_dropdown;
pub mod add_artist;
pub mod add_album;
pub mod song_list;
pub mod loading;
pub mod add_artist;
pub mod dashboard_row;
pub mod dashboard_tile;
pub mod error;
pub mod queue;
pub mod playbar;
pub mod song;
pub mod error_template;
pub mod fancy_input;
pub mod loading;
pub mod menu;
pub mod personal;
pub mod playbar;
pub mod queue;
pub mod sidebar;
pub mod song;
pub mod song_list;
pub mod upload;
pub mod upload_dropdown;

View File

@ -1,12 +1,12 @@
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos::task::spawn_local;
use leptos::html::Div;
use leptos_use::on_click_outside_with_options;
use leptos_use::OnClickOutsideOptions;
use crate::api::auth::logout;
use crate::util::state::GlobalState;
use leptos::html::Div;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
use leptos_use::on_click_outside_with_options;
use leptos_use::OnClickOutsideOptions;
#[component]
pub fn Personal() -> impl IntoView {
@ -20,24 +20,26 @@ pub fn Personal() -> impl IntoView {
#[component]
pub fn Profile() -> impl IntoView {
let dropdown_open = RwSignal::new(false);
let user = GlobalState::logged_in_user();
let user = GlobalState::logged_in_user();
let toggle_dropdown = move |_| dropdown_open.set(!dropdown_open.get());
let toggle_dropdown = move |_| dropdown_open.set(!dropdown_open.get());
let profile_photo = NodeRef::<Div>::new();
let dropdown = NodeRef::<Div>::new();
let _ = on_click_outside_with_options(dropdown, move |_| dropdown_open.set(false),
OnClickOutsideOptions::default().ignore(profile_photo)
let _ = on_click_outside_with_options(
dropdown,
move |_| dropdown_open.set(false),
OnClickOutsideOptions::default().ignore(profile_photo),
);
let user_profile_picture = move || {
user.get().and_then(|user| {
if let Some(user) = user {
user.id?;
Some(format!("/assets/images/profile/{}.webp", user.id.unwrap()))
} else {
None
}
user.id?;
Some(format!("/assets/images/profile/{}.webp", user.id.unwrap()))
} else {
None
}
})
};
@ -48,28 +50,28 @@ pub fn Profile() -> impl IntoView {
fallback=|| view!{
<h1>Not Logged In</h1>
}>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view!{
<h1>Not Logged In</h1>
}>
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
</Show>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view!{
<h1>Not Logged In</h1>
}>
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
</Show>
</Suspense>
</div>
<div class="self-center hover:scale-105 transition-transform cursor-pointer ml-auto"
on:click=toggle_dropdown node_ref=profile_photo>
<Suspense fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }
>
<object class="w-11 h-11 rounded-full pointer-events-none"
<Suspense fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }
>
<object class="w-11 h-11 rounded-full pointer-events-none"
data={user_profile_picture} type="image/webp">
<Icon icon={icondata::CgProfile} width="45" height="45" {..} />
</object>
</Show>
</Suspense>
<Icon icon={icondata::CgProfile} width="45" height="45" {..} />
</object>
</Show>
</Suspense>
</div>
<Show when=dropdown_open >
<div class="absolute bg-bg-light rounded-lg border-2 border-neutral-700 top-12
@ -100,15 +102,14 @@ pub fn DropDownNotLoggedIn() -> impl IntoView {
}
#[component]
pub fn DropDownLoggedIn() -> impl IntoView {
let logout = move |_| {
let logout = move |_| {
spawn_local(async move {
let result = logout().await;
if let Err(err) = result {
log!("Error logging out: {:?}", err);
} else {
let user = GlobalState::logged_in_user();
user.refetch();
let user = GlobalState::logged_in_user();
user.refetch();
log!("Logged out successfully");
}
});

View File

@ -1,15 +1,15 @@
use crate::api::songs;
use crate::models::backend::Artist;
use crate::models::frontend;
use crate::api::songs;
use crate::util::state::GlobalState;
use leptos::ev::MouseEvent;
use leptos::html::{Audio, Div};
use leptos::leptos_dom::*;
use leptos_meta::Title;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_use::{utils::Pausable, use_interval_fn};
use leptos::task::spawn_local;
use leptos_icons::*;
use leptos_meta::Title;
use leptos_use::{use_interval_fn, utils::Pausable};
/// Width and height of the forward/backward skip buttons
const SKIP_BTN_SIZE: &str = "3em";
@ -31,41 +31,41 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
// TODO Handle errors better, when getting audio HTML element and when playing/pausing audio
/// Get the current time and duration of the current song, if available
///
///
/// # Arguments
///
///
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
///
///
/// # Returns
///
///
/// * `None` if the audio element is not available
/// * `Some((current_time, duration))` if the audio element is available
///
///
pub fn get_song_time_duration() -> Option<(f64, f64)> {
GlobalState::play_status().with_untracked(|status| {
if let Some(audio) = status.get_audio() {
Some((audio.current_time(), audio.duration()))
} else {
error!("Unable to get current duration: Audio element not available");
None
}
})
GlobalState::play_status().with_untracked(|status| {
if let Some(audio) = status.get_audio() {
Some((audio.current_time(), audio.duration()))
} else {
error!("Unable to get current duration: Audio element not available");
None
}
})
}
/// Skip to a certain time in the current song, optionally playing it
///
///
/// If the given time is +/- infinity or NaN, logs an error and returns
/// Logs an error if the audio element is not available, or if playing the song fails
///
///
/// # Arguments
///
///
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `time` - The time to skip to, in seconds
///
///
pub fn skip_to(time: f64) {
if time.is_infinite() || time.is_nan() {
error!("Unable to skip to non-finite time: {}", time);
return
return;
}
GlobalState::play_status().update(|status| {
@ -80,11 +80,9 @@ pub fn skip_to(time: f64) {
}
fn toggle_queue() {
GlobalState::play_status().update(|status| {
status.queue_open = !status.queue_open;
});
GlobalState::play_status().update(|status| {
status.queue_open = !status.queue_open;
});
}
/// The play, pause, and skip buttons
@ -123,11 +121,11 @@ fn PlayControls() -> impl IntoView {
};
let skip_forward = move |_| {
if let Some(duration) = get_song_time_duration() {
if let Some(duration) = get_song_time_duration() {
skip_to(duration.1);
} else {
error!("Unable to skip forward: Unable to get current duration");
}
} else {
error!("Unable to skip forward: Unable to get current duration");
}
};
let toggle_play = move |_| {
@ -189,30 +187,42 @@ fn MediaInfo() -> impl IntoView {
let status = GlobalState::play_status();
let name = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
})
status.with(|status| {
status
.queue
.front()
.map_or("No media playing".into(), |song| song.title.clone())
})
});
let artist = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song| Artist::display_list(&song.artists).to_string())
})
});
let artist = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song| {
Artist::display_list(&song.artists).to_string()
})
})
});
let album = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song|
song.album.as_ref().map_or("".into(), |album| album.title.clone()))
})
});
let album = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("".into(), |song| {
song.album
.as_ref()
.map_or("".into(), |album| album.title.clone())
})
})
});
let image = Signal::derive(move || {
status.with(|status| {
status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(),
|song| song.image_path.clone())
})
});
let image = Signal::derive(move || {
status.with(|status| {
status
.queue
.front()
.map_or("/images/placeholders/MusicPlaceholder.svg".into(), |song| {
song.image_path.clone()
})
})
});
view! {
<img class="w-[60px] p-1" src={image}/>
@ -230,27 +240,33 @@ fn LikeDislike() -> impl IntoView {
let status = GlobalState::play_status();
let like_icon = Signal::derive(move || {
status.with(|status| {
match status.queue.front() {
Some(frontend::Song { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled,
_ => icondata::TbThumbUp,
}
status.with(|status| match status.queue.front() {
Some(frontend::Song {
like_dislike: Some((true, _)),
..
}) => icondata::TbThumbUpFilled,
_ => icondata::TbThumbUp,
})
});
let dislike_icon = Signal::derive(move || {
status.with(|status| {
match status.queue.front() {
Some(frontend::Song { like_dislike: Some((_, true)), .. }) => icondata::TbThumbDownFilled,
_ => icondata::TbThumbDown,
}
status.with(|status| match status.queue.front() {
Some(frontend::Song {
like_dislike: Some((_, true)),
..
}) => icondata::TbThumbDownFilled,
_ => icondata::TbThumbDown,
})
});
let toggle_like = move |_| {
status.update(|status| {
match status.queue.front_mut() {
Some(frontend::Song { id, like_dislike: Some((liked, disliked)), .. }) => {
Some(frontend::Song {
id,
like_dislike: Some((liked, disliked)),
..
}) => {
*liked = !*liked;
if *liked {
@ -264,8 +280,10 @@ fn LikeDislike() -> impl IntoView {
error!("Error liking song: {:?}", e);
}
});
},
Some(frontend::Song { id, like_dislike, .. }) => {
}
Some(frontend::Song {
id, like_dislike, ..
}) => {
// This arm should only be reached if like_dislike is None
// In this case, the buttons will show up not filled, indicating that the song is not
// liked or disliked. Therefore, clicking the like button should like the song.
@ -278,7 +296,7 @@ fn LikeDislike() -> impl IntoView {
error!("Error liking song: {:?}", e);
}
});
},
}
_ => {
log!("Unable to like song: No song in queue");
}
@ -289,7 +307,11 @@ fn LikeDislike() -> impl IntoView {
let toggle_dislike = move |_| {
status.update(|status| {
match status.queue.front_mut() {
Some(frontend::Song { id, like_dislike: Some((liked, disliked)), .. }) => {
Some(frontend::Song {
id,
like_dislike: Some((liked, disliked)),
..
}) => {
*disliked = !*disliked;
if *disliked {
@ -303,21 +325,23 @@ fn LikeDislike() -> impl IntoView {
error!("Error disliking song: {:?}", e);
}
});
},
Some(frontend::Song { id, like_dislike, .. }) => {
}
Some(frontend::Song {
id, like_dislike, ..
}) => {
// This arm should only be reached if like_dislike is None
// In this case, the buttons will show up not filled, indicating that the song is not
// liked or disliked. Therefore, clicking the dislike button should dislike the song.
*like_dislike = Some((false, true));
let id = *id;
spawn_local(async move {
spawn_local(async move {
if let Err(e) = songs::set_dislike_song(id, true).await {
error!("Error disliking song: {:?}", e);
}
});
},
}
_ => {
log!("Unable to dislike song: No song in queue");
}
@ -351,10 +375,10 @@ fn ProgressBar(percentage: Signal<f64>) -> impl IntoView {
let width = progress_bar.offset_width() as f64;
let percentage = x_click_pos / width * 100.0;
if let Some(duration) = get_song_time_duration() {
let time = duration.1 * percentage / 100.0;
skip_to(time);
} else {
if let Some(duration) = get_song_time_duration() {
let time = duration.1 * percentage / 100.0;
skip_to(time);
} else {
error!("Unable to skip to time: Unable to get current duration");
}
} else {
@ -381,8 +405,10 @@ fn ProgressBar(percentage: Signal<f64>) -> impl IntoView {
fn QueueToggle() -> impl IntoView {
let update_queue = move |_| {
toggle_queue();
log!("queue button pressed, queue status: {:?}",
GlobalState::play_status().with_untracked(|status| status.queue_open));
log!(
"queue button pressed, queue status: {:?}",
GlobalState::play_status().with_untracked(|status| status.queue_open)
);
};
view! {
@ -397,11 +423,19 @@ fn QueueToggle() -> impl IntoView {
pub fn CustomTitle() -> impl IntoView {
let title = Memo::new(move |_| {
GlobalState::play_status().with(|play_status| {
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
play_status
.queue
.front()
.map_or("LibreTunes".to_string(), |song_data| {
format!(
"{} - {} | {}",
song_data.title.clone(),
Artist::display_list(&song_data.artists),
"LibreTunes"
)
})
})
});
})
});
view! {
<Title text=title />
}
@ -413,42 +447,49 @@ pub fn PlayBar() -> impl IntoView {
let status = GlobalState::play_status();
// Listen for key down events -- arrow keys don't seem to trigger key press events
let _arrow_key_handle = window_event_listener(leptos::ev::keydown, move |e: leptos::ev::KeyboardEvent| {
if e.key() == "ArrowRight" {
e.prevent_default();
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
let _arrow_key_handle =
window_event_listener(leptos::ev::keydown, move |e: leptos::ev::KeyboardEvent| {
if e.key() == "ArrowRight" {
e.prevent_default();
log!(
"Right arrow key pressed, skipping forward by {} seconds",
ARROW_KEY_SKIP_TIME
);
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(time);
} else {
error!("Unable to skip forward: Unable to get current duration");
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(time);
} else {
error!("Unable to skip forward: Unable to get current duration");
}
} else if e.key() == "ArrowLeft" {
e.prevent_default();
log!(
"Left arrow key pressed, skipping backward by {} seconds",
ARROW_KEY_SKIP_TIME
);
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(time);
} else {
error!("Unable to skip backward: Unable to get current duration");
}
}
} else if e.key() == "ArrowLeft" {
e.prevent_default();
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(time);
} else {
error!("Unable to skip backward: Unable to get current duration");
}
}
});
});
// Listen for space bar presses to play/pause
let _space_bar_handle = window_event_listener(leptos::ev::keypress, move |e: leptos::ev::KeyboardEvent| {
if e.key() == " " {
e.prevent_default();
log!("Space bar pressed, toggling play/pause");
let _space_bar_handle =
window_event_listener(leptos::ev::keypress, move |e: leptos::ev::KeyboardEvent| {
if e.key() == " " {
e.prevent_default();
log!("Space bar pressed, toggling play/pause");
status.update(|status| status.playing = !status.playing);
}
});
status.update(|status| status.playing = !status.playing);
}
});
// Keep a reference to the audio element so we can set its source and play/pause it
let audio_ref = NodeRef::<Audio>::new();
@ -475,16 +516,11 @@ pub fn PlayBar() -> impl IntoView {
let (total_secs, set_total_secs) = signal(0);
let (percentage, set_percentage) = signal(0.0);
let current_song_id = Memo::new(move |_| {
status.with(|status| {
status.queue.front().map(|song| song.id)
})
});
let current_song_id =
Memo::new(move |_| status.with(|status| status.queue.front().map(|song| song.id)));
let current_song_src = Memo::new(move |_| {
status.with(|status| {
status.queue.front().map(|song| song.song_path.clone())
})
status.with(|status| status.queue.front().map(|song| song.song_path.clone()))
});
Effect::new(move |_| {
@ -492,13 +528,13 @@ pub fn PlayBar() -> impl IntoView {
GlobalState::play_status().with_untracked(|status| {
if let Some(audio) = status.get_audio() {
if let Some(src) = src {
audio.set_src(src);
audio.set_src(src);
if let Err(e) = audio.play() {
error!("Error playing audio: {:?}", e);
} else {
log!("Audio playing");
}
if let Err(e) = audio.play() {
error!("Error playing audio: {:?}", e);
} else {
log!("Audio playing");
}
} else {
audio.set_src("");
}
@ -512,26 +548,29 @@ pub fn PlayBar() -> impl IntoView {
// Track the last song that was added to the history to prevent duplicates
let last_history_song_id = RwSignal::new(None);
let Pausable {
let Pausable {
is_active: hist_timeout_pending,
resume: resume_hist_timeout,
pause: pause_hist_timeout,
..
} = use_interval_fn(move || {
if last_history_song_id.get_untracked() == current_song_id.get_untracked() {
return;
}
} = use_interval_fn(
move || {
if last_history_song_id.get_untracked() == current_song_id.get_untracked() {
return;
}
if let Some(current_song_id) = current_song_id.get_untracked() {
last_history_song_id.set(Some(current_song_id));
if let Some(current_song_id) = current_song_id.get_untracked() {
last_history_song_id.set(Some(current_song_id));
spawn_local(async move {
if let Err(e) = crate::api::history::add_history(current_song_id).await {
error!("Error adding song {} to history: {}", current_song_id, e);
}
});
}
}, HISTORY_LISTEN_THRESHOLD * 1000);
spawn_local(async move {
if let Err(e) = crate::api::history::add_history(current_song_id).await {
error!("Error adding song {} to history: {}", current_song_id, e);
}
});
}
},
HISTORY_LISTEN_THRESHOLD * 1000,
);
// Initially pause the timeout, since the audio starts off paused
pause_hist_timeout();
@ -554,7 +593,10 @@ pub fn PlayBar() -> impl IntoView {
set_total_secs(audio.duration() as i64);
if elapsed_secs.get_untracked() > 0 {
set_percentage(elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64 * 100.0);
set_percentage(
elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64
* 100.0,
);
} else {
set_percentage(0.0);
}

View File

@ -1,132 +1,142 @@
use crate::models::backend::Artist;
use crate::components::song::Song;
use crate::models::backend::Artist;
use crate::util::state::GlobalState;
use leptos::ev::DragEvent;
use leptos::ev::MouseEvent;
use leptos::html::Div;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos::ev::DragEvent;
use leptos_use::on_click_outside_with_options;
use leptos_use::OnClickOutsideOptions;
use leptos::html::Div;
const RM_BTN_SIZE: &str = "2.5rem";
fn remove_song_fn(index: usize) {
if index == 0 {
log!("Error: Trying to remove currently playing song (index 0) from queue");
} else {
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
GlobalState::play_status().update(|status| {
status.queue.remove(index);
});
}
if index == 0 {
log!("Error: Trying to remove currently playing song (index 0) from queue");
} else {
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
GlobalState::play_status().update(|status| {
status.queue.remove(index);
});
}
}
#[component]
pub fn Queue() -> impl IntoView {
let status = GlobalState::play_status();
let status = GlobalState::play_status();
let remove_song = move |index: usize| {
remove_song_fn(index);
log!("Removed song {}", index + 1);
};
let remove_song = move |index: usize| {
remove_song_fn(index);
log!("Removed song {}", index + 1);
};
let prevent_focus = move |e: MouseEvent| {
let prevent_focus = move |e: MouseEvent| {
e.prevent_default();
};
let index_being_dragged = RwSignal::new(-1);
let index_being_dragged = RwSignal::new(-1);
let index_being_hovered = RwSignal::new(-1);
let index_being_hovered = RwSignal::new(-1);
let on_drag_start = move |_e: DragEvent, index: usize| {
// set the index of the item being dragged
index_being_dragged.set(index as i32);
let on_drag_start = move |_e: DragEvent, index: usize| {
// set the index of the item being dragged
index_being_dragged.set(index as i32);
};
let on_drop = move |e: DragEvent| {
e.prevent_default();
// if the index of the item being dragged is not the same as the index of the item being hovered over
if index_being_dragged.get() != index_being_hovered.get() && index_being_dragged.get() > 0 && index_being_hovered.get() > 0 {
// get the index of the item being dragged
let dragged_index = index_being_dragged.get_untracked() as usize;
// get the index of the item being hovered over
let hovered_index = index_being_hovered.get_untracked() as usize;
// update the queue
status.update(|status| {
// remove the dragged item from the list
let dragged_item = status.queue.remove(dragged_index);
// insert the dragged item at the index of the item being hovered over
status.queue.insert(hovered_index, dragged_item.unwrap());
});
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
log!("drag end. Moved item from index {} to index {}", dragged_index, hovered_index);
}
else {
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
}
};
let on_drag_enter = move |_e: DragEvent, index: usize| {
// set the index of the item being hovered over
index_being_hovered.set(index as i32);
};
let on_drop = move |e: DragEvent| {
e.prevent_default();
// if the index of the item being dragged is not the same as the index of the item being hovered over
if index_being_dragged.get() != index_being_hovered.get()
&& index_being_dragged.get() > 0
&& index_being_hovered.get() > 0
{
// get the index of the item being dragged
let dragged_index = index_being_dragged.get_untracked() as usize;
// get the index of the item being hovered over
let hovered_index = index_being_hovered.get_untracked() as usize;
// update the queue
status.update(|status| {
// remove the dragged item from the list
let dragged_item = status.queue.remove(dragged_index);
// insert the dragged item at the index of the item being hovered over
status.queue.insert(hovered_index, dragged_item.unwrap());
});
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
log!(
"drag end. Moved item from index {} to index {}",
dragged_index,
hovered_index
);
} else {
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
}
};
let on_drag_over = move |e: DragEvent| {
e.prevent_default();
};
let on_drag_enter = move |_e: DragEvent, index: usize| {
// set the index of the item being hovered over
index_being_hovered.set(index as i32);
};
let queue = NodeRef::<Div>::new();
let _ = on_click_outside_with_options(queue, move |_| {
status.update(|status| {
status.queue_open = false;
});
}, OnClickOutsideOptions::default().ignore(["#queue-toggle-btn"]));
let on_drag_over = move |e: DragEvent| {
e.prevent_default();
};
view!{
<Show
when=move || status.with(|status| status.queue_open)
fallback=|| view!{""}>
<div class="queue" node_ref=queue>
<div class="queue-header">
<h2>Queue</h2>
</div>
<ul>
{
move || status.with(|status| status.queue.iter()
.enumerate()
.map(|(index, song)| view! {
<div class="queue-item"
draggable="true"
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
on:drop=on_drop
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over
>
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
<Show
when=move || index != 0
fallback=|| view!{
<p>Playing</p>
}>
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
<Icon width=RM_BTN_SIZE height=RM_BTN_SIZE icon={icondata::CgTrash} {..} class="remove-song" />
</button>
</Show>
</div>
})
.collect::<Vec<_>>())
}
</ul>
</div>
</Show>
let queue = NodeRef::<Div>::new();
let _ = on_click_outside_with_options(
queue,
move |_| {
status.update(|status| {
status.queue_open = false;
});
},
OnClickOutsideOptions::default().ignore(["#queue-toggle-btn"]),
);
}
view! {
<Show
when=move || status.with(|status| status.queue_open)
fallback=|| view!{""}>
<div class="queue" node_ref=queue>
<div class="queue-header">
<h2>Queue</h2>
</div>
<ul>
{
move || status.with(|status| status.queue.iter()
.enumerate()
.map(|(index, song)| view! {
<div class="queue-item"
draggable="true"
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
on:drop=on_drop
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over
>
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
<Show
when=move || index != 0
fallback=|| view!{
<p>Playing</p>
}>
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
<Icon width=RM_BTN_SIZE height=RM_BTN_SIZE icon={icondata::CgTrash} {..} class="remove-song" />
</button>
</Show>
</div>
})
.collect::<Vec<_>>())
}
</ul>
</div>
</Show>
}
}

View File

@ -1,9 +1,13 @@
use crate::components::menu::*;
use leptos::prelude::*;
use leptos_icons::*;
use crate::components::menu::*;
#[component]
pub fn Sidebar(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
pub fn Sidebar(
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="flex flex-col">
<Menu upload_open add_artist_open add_album_open />

View File

@ -2,13 +2,13 @@ use leptos::prelude::*;
#[component]
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
view!{
<div class="queue-song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
</div>
</div>
}
}
view! {
<div class="queue-song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
</div>
</div>
}
}

View File

@ -1,284 +1,308 @@
use std::rc::Rc;
use leptos::prelude::*;
use leptos::either::*;
use leptos::logging::*;
use leptos_icons::*;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
use crate::api::songs::*;
use crate::models::frontend;
use crate::models::backend::{Album, Artist};
use crate::models::frontend;
use crate::util::state::GlobalState;
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
#[component]
pub fn SongList(songs: Vec<frontend::Song>) -> impl IntoView {
__SongListInner(songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(), false)
__SongListInner(
songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(),
false,
)
}
#[component]
pub fn SongListExtra<T>(songs: Vec<(frontend::Song, T)>) -> impl IntoView where
T: Clone + IntoView + 'static
pub fn SongListExtra<T>(songs: Vec<(frontend::Song, T)>) -> impl IntoView
where
T: Clone + IntoView + 'static,
{
__SongListInner(songs, true)
__SongListInner(songs, true)
}
// TODO these arguments shouldn't need a leading underscore,
// but for some reason the compiler thinks they are unused
#[component]
fn SongListInner<T>(_songs: Vec<(frontend::Song, T)>, _show_extra: bool) -> impl IntoView where
T: Clone + IntoView + 'static
fn SongListInner<T>(_songs: Vec<(frontend::Song, T)>, _show_extra: bool) -> impl IntoView
where
T: Clone + IntoView + 'static,
{
let songs = Rc::new(_songs);
let songs_2 = songs.clone();
let songs = Rc::new(_songs);
let songs_2 = songs.clone();
// Signal that acts as a callback for a song list item to queue songs after it in the list
let (handle_queue_remaining, do_queue_remaining) = signal(None);
Effect::new(move |_| {
let clicked_index = handle_queue_remaining.get();
// Signal that acts as a callback for a song list item to queue songs after it in the list
let (handle_queue_remaining, do_queue_remaining) = signal(None);
Effect::new(move |_| {
let clicked_index = handle_queue_remaining.get();
if let Some(index) = clicked_index {
GlobalState::play_status().update(|status| {
let song: &(frontend::Song, T) = songs.get(index).expect("Invalid song list item index");
if let Some(index) = clicked_index {
GlobalState::play_status().update(|status| {
let song: &(frontend::Song, T) =
songs.get(index).expect("Invalid song list item index");
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
// If the clicked song is already at the front of the queue, just play it
status.playing = true;
} else {
// Otherwise, add the currently playing song to the history,
// clear the queue, and queue the clicked song and other after it
if let Some(last_playing) = status.queue.pop_front() {
status.history.push_back(last_playing);
}
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
// If the clicked song is already at the front of the queue, just play it
status.playing = true;
} else {
// Otherwise, add the currently playing song to the history,
// clear the queue, and queue the clicked song and other after it
if let Some(last_playing) = status.queue.pop_front() {
status.history.push_back(last_playing);
}
status.queue.clear();
status.queue.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
status.playing = true;
}
});
}
});
status.queue.clear();
status
.queue
.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
status.playing = true;
}
});
}
});
view! {
<table class="w-full">
<tbody>
{
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
let song_id = song.id;
let playing = RwSignal::new(false);
view! {
<table class="w-full">
<tbody>
{
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
let song_id = song.id;
let playing = RwSignal::new(false);
Effect::new(move |_| {
GlobalState::play_status().with(|status| {
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
});
});
Effect::new(move |_| {
GlobalState::play_status().with(|status| {
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
});
});
view! {
<SongListItem song={song.clone()} song_playing=playing.into()
extra={if _show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
}
}).collect::<Vec<_>>()
}
</tbody>
</table>
}
view! {
<SongListItem song={song.clone()} song_playing=playing.into()
extra={if _show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
}
}).collect::<Vec<_>>()
}
</tbody>
</table>
}
}
#[component]
pub fn SongListItem<T>(song: frontend::Song, song_playing: Signal<bool>, extra: Option<T>,
list_index: usize, do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView where
T: IntoView + 'static
pub fn SongListItem<T>(
song: frontend::Song,
song_playing: Signal<bool>,
extra: Option<T>,
list_index: usize,
do_queue_remaining: WriteSignal<Option<usize>>,
) -> impl IntoView
where
T: IntoView + 'static,
{
let liked = RwSignal::new(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
let disliked = RwSignal::new(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false));
view! {
<tr class="group border-b border-t border-neutral-600 last-of-type:border-b-0
first-of-type:border-t-0 hover:bg-neutral-700 [&>*]:px-2">
<td class="relative w-13 h-13"><SongImage image_path=song.image_path song_playing
list_index do_queue_remaining /></td>
<td><p>{song.title}</p></td>
<td></td>
<td><SongArtists artists=song.artists /></td>
<td></td>
<td><SongAlbum album=song.album /></td>
<td></td>
<td><SongLikeDislike song_id=song.id liked disliked/></td>
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
{extra.map(|extra| view! {
<td></td>
<td>{extra}</td>
})}
</tr>
}
let liked = RwSignal::new(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
let disliked = RwSignal::new(
song.like_dislike
.map(|(_, disliked)| disliked)
.unwrap_or(false),
);
view! {
<tr class="group border-b border-t border-neutral-600 last-of-type:border-b-0
first-of-type:border-t-0 hover:bg-neutral-700 [&>*]:px-2">
<td class="relative w-13 h-13"><SongImage image_path=song.image_path song_playing
list_index do_queue_remaining /></td>
<td><p>{song.title}</p></td>
<td></td>
<td><SongArtists artists=song.artists /></td>
<td></td>
<td><SongAlbum album=song.album /></td>
<td></td>
<td><SongLikeDislike song_id=song.id liked disliked/></td>
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
{extra.map(|extra| view! {
<td></td>
<td>{extra}</td>
})}
</tr>
}
}
/// Display the song's image, with an overlay if the song is playing
/// When the song list item is hovered, the overlay will show the play button
#[component]
pub fn SongImage(image_path: String, song_playing: Signal<bool>, list_index: usize,
do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView
{
let play_song = move |_| {
do_queue_remaining.set(Some(list_index));
};
pub fn SongImage(
image_path: String,
song_playing: Signal<bool>,
list_index: usize,
do_queue_remaining: WriteSignal<Option<usize>>,
) -> impl IntoView {
let play_song = move |_| {
do_queue_remaining.set(Some(list_index));
};
let pause_song = move |_| {
GlobalState::play_status().update(|status| {
status.playing = false;
});
};
let pause_song = move |_| {
GlobalState::play_status().update(|status| {
status.playing = false;
});
};
view! {
<img class="group-hover:brightness-45" src={image_path}/>
{move || if song_playing.get() {
Either::Left(view! { <Icon icon={icondata::BsPauseFill} on:click={pause_song}
{..} class="w-6 h-6 absolute top-1/2 left-1/2 translate-[-50%]" /> })
} else {
Either::Right(view! { <Icon icon={icondata::BsPlayFill} on:click={play_song}
{..} class="w-6 h-6 opacity-0 group-hover:opacity-100 absolute top-1/2 left-1/2 translate-[-50%]" /> })
}}
}
view! {
<img class="group-hover:brightness-45" src={image_path}/>
{move || if song_playing.get() {
Either::Left(view! { <Icon icon={icondata::BsPauseFill} on:click={pause_song}
{..} class="w-6 h-6 absolute top-1/2 left-1/2 translate-[-50%]" /> })
} else {
Either::Right(view! { <Icon icon={icondata::BsPlayFill} on:click={play_song}
{..} class="w-6 h-6 opacity-0 group-hover:opacity-100 absolute top-1/2 left-1/2 translate-[-50%]" /> })
}}
}
}
/// Displays a song's artists, with links to their artist pages
#[component]
pub fn SongArtists(artists: Vec<Artist>) -> impl IntoView {
let num_artists = artists.len() as isize;
let num_artists = artists.len() as isize;
artists.iter().enumerate().map(|(i, artist)| {
let i = i as isize;
view! {
{
if let Some(id) = artist.id {
Either::Left(view! { <a class="hover:underline active:text-controls-active"
href={format!("/artist/{id}")}>{artist.name.clone()}</a> })
} else {
Either::Right(view! { <span>{artist.name.clone()}</span> })
}
}
{
use std::cmp::Ordering;
artists
.iter()
.enumerate()
.map(|(i, artist)| {
let i = i as isize;
match i.cmp(&(num_artists - 2)) {
Ordering::Less => ", ",
Ordering::Equal => " & ",
Ordering::Greater => "",
}
}
}
}).collect::<Vec<_>>()
view! {
{
if let Some(id) = artist.id {
Either::Left(view! { <a class="hover:underline active:text-controls-active"
href={format!("/artist/{id}")}>{artist.name.clone()}</a> })
} else {
Either::Right(view! { <span>{artist.name.clone()}</span> })
}
}
{
use std::cmp::Ordering;
match i.cmp(&(num_artists - 2)) {
Ordering::Less => ", ",
Ordering::Equal => " & ",
Ordering::Greater => "",
}
}
}
})
.collect::<Vec<_>>()
}
/// Display a song's album, with a link to the album page
#[component]
pub fn SongAlbum(album: Option<Album>) -> impl IntoView {
album.as_ref().map(|album| {
view! {
<span>
{
if let Some(id) = album.id {
Either::Left(view! { <a class="hover:underline active:text-controls-active"
href={format!("/album/{id}")}>{album.title.clone()}</a> })
} else {
Either::Right(view! { <span>{album.title.clone()}</span> })
}
}
</span>
}
})
album.as_ref().map(|album| {
view! {
<span>
{
if let Some(id) = album.id {
Either::Left(view! { <a class="hover:underline active:text-controls-active"
href={format!("/album/{id}")}>{album.title.clone()}</a> })
} else {
Either::Right(view! { <span>{album.title.clone()}</span> })
}
}
</span>
}
})
}
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
#[component]
pub fn SongLikeDislike(
#[prop(into)]
song_id: Signal<i32>,
liked: RwSignal<bool>,
disliked: RwSignal<bool>) -> impl IntoView
{
let like_icon = Signal::derive(move || {
if liked.get() {
icondata::TbThumbUpFilled
} else {
icondata::TbThumbUp
}
});
#[prop(into)] song_id: Signal<i32>,
liked: RwSignal<bool>,
disliked: RwSignal<bool>,
) -> impl IntoView {
let like_icon = Signal::derive(move || {
if liked.get() {
icondata::TbThumbUpFilled
} else {
icondata::TbThumbUp
}
});
let dislike_icon = Signal::derive(move || {
if disliked.get() {
icondata::TbThumbDownFilled
} else {
icondata::TbThumbDown
}
});
let dislike_icon = Signal::derive(move || {
if disliked.get() {
icondata::TbThumbDownFilled
} else {
icondata::TbThumbDown
}
});
let like_class = Signal::derive(move || {
if liked.get() {
""
} else {
"opacity-0 group-hover:opacity-100"
}
});
let like_class = Signal::derive(move || {
if liked.get() {
""
} else {
"opacity-0 group-hover:opacity-100"
}
});
let dislike_class = Signal::derive(move || {
if disliked.get() {
""
} else {
"opacity-0 group-hover:opacity-100"
}
});
let dislike_class = Signal::derive(move || {
if disliked.get() {
""
} else {
"opacity-0 group-hover:opacity-100"
}
});
// If an error occurs, check the like/dislike status again to ensure consistency
let check_like_dislike = move || {
spawn_local(async move {
if let Ok((like, dislike)) = get_like_dislike_song(song_id.get_untracked()).await {
liked.set(like);
disliked.set(dislike);
}
});
};
// If an error occurs, check the like/dislike status again to ensure consistency
let check_like_dislike = move || {
spawn_local(async move {
if let Ok((like, dislike)) = get_like_dislike_song(song_id.get_untracked()).await {
liked.set(like);
disliked.set(dislike);
}
});
};
let toggle_like = move |_| {
let new_liked = !liked.get_untracked();
liked.set(new_liked);
disliked.set(disliked.get_untracked() && !liked.get_untracked());
spawn_local(async move {
match set_like_song(song_id.get_untracked(), new_liked).await {
Ok(_) => {},
Err(e) => {
error!("Error setting like: {}", e);
check_like_dislike();
}
}
});
};
let toggle_like = move |_| {
let new_liked = !liked.get_untracked();
liked.set(new_liked);
disliked.set(disliked.get_untracked() && !liked.get_untracked());
let toggle_dislike = move |_| {
disliked.set(!disliked.get_untracked());
liked.set(liked.get_untracked() && !disliked.get_untracked());
spawn_local(async move {
match set_like_song(song_id.get_untracked(), new_liked).await {
Ok(_) => {}
Err(e) => {
error!("Error setting like: {}", e);
check_like_dislike();
}
}
});
};
spawn_local(async move {
match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await {
Ok(_) => {},
Err(e) => {
error!("Error setting dislike: {}", e);
check_like_dislike();
}
}
});
};
let toggle_dislike = move |_| {
disliked.set(!disliked.get_untracked());
liked.set(liked.get_untracked() && !disliked.get_untracked());
view! {
<button class="control scale-x-[-1]" on:click=toggle_dislike>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={dislike_icon} {..} class=dislike_class />
</button>
<button class="control" on:click=toggle_like>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={like_icon} {..} class=like_class />
</button>
}
spawn_local(async move {
match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await {
Ok(_) => {}
Err(e) => {
error!("Error setting dislike: {}", e);
check_like_dislike();
}
}
});
};
view! {
<button class="control scale-x-[-1]" on:click=toggle_dislike>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={dislike_icon} {..} class=dislike_class />
</button>
<button class="control" on:click=toggle_like>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={like_icon} {..} class=like_class />
</button>
}
}

View File

@ -1,242 +1,252 @@
use std::sync::Arc;
use crate::api::search::search_albums;
use crate::api::search::search_artists;
use crate::models::backend::{Album, Artist};
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
use leptos_router::components::Form;
use std::sync::Arc;
use web_sys::Response;
use leptos::task::spawn_local;
use crate::api::search::search_artists;
use crate::api::search::search_albums;
use crate::models::backend::{Artist, Album};
#[component]
pub fn UploadBtn(dialog_open: RwSignal<bool>) -> impl IntoView {
let open_dialog = move |_| {
dialog_open.set(true);
};
let open_dialog = move |_| {
dialog_open.set(true);
};
view! {
<button class="upload-btn add-btns" on:click=open_dialog>
Upload Song
</button>
}
view! {
<button class="upload-btn add-btns" on:click=open_dialog>
Upload Song
</button>
}
}
#[component]
pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
// Create signals for the artist input and the filtered artists
let (artists, set_artists) = signal("".to_string());
let (filtered_artists, set_filtered_artists) = signal(vec![]);
// Create signals for the artist input and the filtered artists
let (artists, set_artists) = signal("".to_string());
let (filtered_artists, set_filtered_artists) = signal(vec![]);
let (albums, set_albums) = signal("".to_string());
let (filtered_albums, set_filtered_albums) = signal(vec![]);
let (albums, set_albums) = signal("".to_string());
let (filtered_albums, set_filtered_albums) = signal(vec![]);
let (error_msg, set_error_msg) = signal::<Option<String>>(None);
let (error_msg, set_error_msg) = signal::<Option<String>>(None);
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
// Create a filter function to handle filtering artists
// Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend
let handle_filter_artists = move |ev: leptos::ev::Event| {
ev.prevent_default();
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
// Create a filter function to handle filtering artists
// Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend
let handle_filter_artists = move |ev: leptos::ev::Event| {
ev.prevent_default();
let artist_input: String = event_target_value(&ev);
let artist_input: String = event_target_value(&ev);
//Get the artist that we are currently searching for
let mut all_artists: Vec<&str> = artist_input.split(",").collect();
let search = all_artists.pop().unwrap().to_string();
//Update the artist signal with the input
set_artists.update(|value: &mut String| *value = artist_input);
//Get the artist that we are currently searching for
let mut all_artists: Vec<&str> = artist_input.split(",").collect();
let search = all_artists.pop().unwrap().to_string();
spawn_local(async move {
let filter_results = search_artists(search, 3).await;
if let Err(err) = filter_results {
log!("Error filtering artists: {:?}", err);
} else if let Ok(artists) = filter_results {
log!("Filtered artists: {:?}", artists);
//Update the artist signal with the input
set_artists.update(|value: &mut String| *value = artist_input);
set_filtered_artists.update(|value| *value = artists);
}
});
};
// Create a filter function to handle filtering albums
// Allow users to search for albums by title, converts the album title to album id to be handed off to backend
let handle_filter_albums = move |ev: leptos::ev::Event| {
ev.prevent_default();
spawn_local(async move {
let filter_results = search_artists(search, 3).await;
if let Err(err) = filter_results {
log!("Error filtering artists: {:?}", err);
} else if let Ok(artists) = filter_results {
log!("Filtered artists: {:?}", artists);
let album_input: String = event_target_value(&ev);
//Update the album signal with the input
set_albums.update(|value: &mut String| *value = album_input);
set_filtered_artists.update(|value| *value = artists);
}
});
};
// Create a filter function to handle filtering albums
// Allow users to search for albums by title, converts the album title to album id to be handed off to backend
let handle_filter_albums = move |ev: leptos::ev::Event| {
ev.prevent_default();
spawn_local(async move {
let filter_results = search_albums(albums.get_untracked(), 3).await;
if let Err(err) = filter_results {
log!("Error filtering albums: {:?}", err);
} else if let Ok(albums) = filter_results {
log!("Filtered albums: {:?}", albums);
set_filtered_albums.update(|value| *value = albums);
}
});
};
let album_input: String = event_target_value(&ev);
let handle_response = Arc::new(move |response: &Response| {
if response.ok() {
set_error_msg.update(|value| *value = None);
set_filtered_artists.update(|value| *value = vec![]);
set_filtered_albums.update(|value| *value = vec![]);
set_artists.update(|value| *value = "".to_string());
set_albums.update(|value| *value = "".to_string());
open.set(false);
} else {
// TODO: Extract error message from response
set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
}
});
//Update the album signal with the input
set_albums.update(|value: &mut String| *value = album_input);
view! {
<Show when=open fallback=move || view! {}>
<dialog class="upload-container" open=open>
<div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
<div class="upload-header">
<h1>Upload Song</h1>
</div>
<Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data")
on_response=handle_response.clone() {..} class="upload-form" >
<div class="input-bx">
<input type="text" name="title" required class="text-input" required/>
<span>Title</span>
</div>
<div class="artists has-search">
<div class="input-bx">
<input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/>
<span>Artists</span>
</div>
<Show
when=move || {!filtered_artists.get().is_empty()}
fallback=move || view! {}
>
<ul class="artist_results search-results">
{
move || filtered_artists.get().iter().map(|filtered_artist| view! {
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
}).collect::<Vec<_>>()
}
</ul>
</Show>
</div>
<div class="albums has-search">
<div class="input-bx">
<input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/>
<span>Album ID</span>
</div>
<Show
when=move || {!filtered_albums.get().is_empty()}
fallback=move || view! {}
>
<ul class="album_results search-results">
{
move || filtered_albums.get().iter().map(|filtered_album| view! {
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
}).collect::<Vec<_>>()
}
</ul>
</Show>
</div>
<div class="input-bx">
<input type="number" name="track_number" class="text-input"/>
<span>Track Number</span>
</div>
<div class="release-date">
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date" name="release_date"/>
</div>
<div class="file">
<span>File</span>
<input class="info" type="file" accept=".mp3" name="file" required/>
</div>
<button type="submit" class="upload-button">Upload</button>
</Form>
<Show
when=move || {error_msg.get().is_some()}
fallback=move || view! {}
>
<div class="error-msg">
<Icon icon={icondata::IoAlertCircleSharp} />
{error_msg.get().unwrap()}
</div>
</Show>
</dialog>
</Show>
}
spawn_local(async move {
let filter_results = search_albums(albums.get_untracked(), 3).await;
if let Err(err) = filter_results {
log!("Error filtering albums: {:?}", err);
} else if let Ok(albums) = filter_results {
log!("Filtered albums: {:?}", albums);
set_filtered_albums.update(|value| *value = albums);
}
});
};
let handle_response = Arc::new(move |response: &Response| {
if response.ok() {
set_error_msg.update(|value| *value = None);
set_filtered_artists.update(|value| *value = vec![]);
set_filtered_albums.update(|value| *value = vec![]);
set_artists.update(|value| *value = "".to_string());
set_albums.update(|value| *value = "".to_string());
open.set(false);
} else {
// TODO: Extract error message from response
set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
}
});
view! {
<Show when=open fallback=move || view! {}>
<dialog class="upload-container" open=open>
<div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
<div class="upload-header">
<h1>Upload Song</h1>
</div>
<Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data")
on_response=handle_response.clone() {..} class="upload-form" >
<div class="input-bx">
<input type="text" name="title" required class="text-input" required/>
<span>Title</span>
</div>
<div class="artists has-search">
<div class="input-bx">
<input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/>
<span>Artists</span>
</div>
<Show
when=move || {!filtered_artists.get().is_empty()}
fallback=move || view! {}
>
<ul class="artist_results search-results">
{
move || filtered_artists.get().iter().map(|filtered_artist| view! {
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
}).collect::<Vec<_>>()
}
</ul>
</Show>
</div>
<div class="albums has-search">
<div class="input-bx">
<input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/>
<span>Album ID</span>
</div>
<Show
when=move || {!filtered_albums.get().is_empty()}
fallback=move || view! {}
>
<ul class="album_results search-results">
{
move || filtered_albums.get().iter().map(|filtered_album| view! {
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
}).collect::<Vec<_>>()
}
</ul>
</Show>
</div>
<div class="input-bx">
<input type="number" name="track_number" class="text-input"/>
<span>Track Number</span>
</div>
<div class="release-date">
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date" name="release_date"/>
</div>
<div class="file">
<span>File</span>
<input class="info" type="file" accept=".mp3" name="file" required/>
</div>
<button type="submit" class="upload-button">Upload</button>
</Form>
<Show
when=move || {error_msg.get().is_some()}
fallback=move || view! {}
>
<div class="error-msg">
<Icon icon={icondata::IoAlertCircleSharp} />
{error_msg.get().unwrap()}
</div>
</Show>
</dialog>
</Show>
}
}
#[component]
pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSignal<String>, set_filtered: WriteSignal<Vec<Artist>>) -> impl IntoView {
// Converts artist name to artist id and adds it to the artist input
let add_artist = move |_| {
//Create an empty string to hold previous artist ids
let mut s: String = String::from("");
//Get the current artist input
let all_artirts: String = artists.get();
//Split the input into a vector of artists separated by commas
let mut ids: Vec<&str> = all_artirts.split(",").collect();
//If there is only one artist in the input, get their id equivalent and add it to the string
if ids.len() == 1 {
let value_str = match artist.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
s.push_str(&value_str);
s.push(',');
set_artists.update(|value| *value = s);
//If there are multiple artists in the input, pop the last artist by string off the vector,
//get their id equivalent, and add it to the string
} else {
ids.pop();
for id in ids {
s.push_str(id);
s.push(',');
}
let value_str = match artist.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
s.push_str(&value_str);
s.push(',');
set_artists.update(|value| *value = s);
}
//Clear the search results
set_filtered.update(|value| *value = vec![]);
};
pub fn Artist(
artist: Artist,
artists: ReadSignal<String>,
set_artists: WriteSignal<String>,
set_filtered: WriteSignal<Vec<Artist>>,
) -> impl IntoView {
// Converts artist name to artist id and adds it to the artist input
let add_artist = move |_| {
//Create an empty string to hold previous artist ids
let mut s: String = String::from("");
//Get the current artist input
let all_artirts: String = artists.get();
//Split the input into a vector of artists separated by commas
let mut ids: Vec<&str> = all_artirts.split(",").collect();
//If there is only one artist in the input, get their id equivalent and add it to the string
if ids.len() == 1 {
let value_str = match artist.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
s.push_str(&value_str);
s.push(',');
set_artists.update(|value| *value = s);
//If there are multiple artists in the input, pop the last artist by string off the vector,
//get their id equivalent, and add it to the string
} else {
ids.pop();
for id in ids {
s.push_str(id);
s.push(',');
}
let value_str = match artist.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
s.push_str(&value_str);
s.push(',');
set_artists.update(|value| *value = s);
}
//Clear the search results
set_filtered.update(|value| *value = vec![]);
};
view! {
<div class="artist result" on:click=add_artist>
{artist.name.clone()}
</div>
}
view! {
<div class="artist result" on:click=add_artist>
{artist.name.clone()}
</div>
}
}
#[component]
pub fn Album(album: Album, _albums: ReadSignal<String>, set_albums: WriteSignal<String>, set_filtered: WriteSignal<Vec<Album>>) -> impl IntoView {
//Converts album title to album id to upload a song
let add_album = move |_| {
let value_str = match album.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
set_albums.update(|value| *value = value_str);
set_filtered.update(|value| *value = vec![]);
};
view! {
<div class="album result" on:click=add_album>
{album.title.clone()}
</div>
}
}
pub fn Album(
album: Album,
_albums: ReadSignal<String>,
set_albums: WriteSignal<String>,
set_filtered: WriteSignal<Vec<Album>>,
) -> impl IntoView {
//Converts album title to album id to upload a song
let add_album = move |_| {
let value_str = match album.id {
Some(v) => v.to_string(),
None => String::from("None"),
};
set_albums.update(|value| *value = value_str);
set_filtered.update(|value| *value = vec![]);
};
view! {
<div class="album result" on:click=add_album>
{album.title.clone()}
</div>
}
}

View File

@ -1,8 +1,8 @@
use crate::components::add_album::*;
use crate::components::add_artist::*;
use crate::components::upload::*;
use leptos::prelude::*;
use leptos_icons::*;
use crate::components::upload::*;
use crate::components::add_artist::*;
use crate::components::add_album::*;
#[component]
pub fn UploadDropdownBtn(dropdown_open: RwSignal<bool>) -> impl IntoView {
@ -11,15 +11,20 @@ pub fn UploadDropdownBtn(dropdown_open: RwSignal<bool>) -> impl IntoView {
};
view! {
<button class={move || if dropdown_open() {"upload-dropdown-btn upload-dropdown-btn-active"} else {"upload-dropdown-btn"}} on:click=open_dropdown>
<div class="add-sign">
<Icon icon={icondata::IoAddSharp} />
</div>
</button>
<div class="add-sign">
<Icon icon={icondata::IoAddSharp} />
</div>
</button>
}
}
#[component]
pub fn UploadDropdown(dropdown_open: RwSignal<bool>, upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
pub fn UploadDropdown(
dropdown_open: RwSignal<bool>,
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="upload-dropdown" on:click=move |_| dropdown_open.set(false)>
<UploadBtn dialog_open=upload_open />
@ -27,4 +32,4 @@ pub fn UploadDropdown(dropdown_open: RwSignal<bool>, upload_open: RwSignal<bool>
<AddAlbumBtn add_album_open=add_album_open/>
</div>
}
}
}

View File

@ -22,22 +22,16 @@
clippy::unused_self,
clippy::use_debug,
clippy::useless_let_if_seq,
clippy::wildcard_dependencies,
clippy::wildcard_dependencies
)]
#![allow(
clippy::unused_unit,
clippy::unit_arg,
clippy::type_complexity,
)]
#![allow(clippy::unused_unit, clippy::unit_arg, clippy::type_complexity)]
#![recursion_limit = "256"]
pub mod api;
pub mod app;
pub mod components;
pub mod models;
pub mod pages;
pub mod components;
pub mod api;
pub mod util;
use cfg_if::cfg_if;
@ -63,4 +57,3 @@ if #[cfg(feature = "hydrate")] {
}
}
}

View File

@ -6,20 +6,26 @@ extern crate diesel_migrations;
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{routing::get, Router, extract::Path, middleware::from_fn};
use axum::{extract::Path, middleware::from_fn, routing::get, Router};
use axum_login::tower_sessions::SessionManagerLayer;
use axum_login::AuthManagerLayerBuilder;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use libretunes::app::*;
use libretunes::util::auth_backend::AuthBackend;
use libretunes::util::fileserv::{
file_and_error_handler, get_asset_file, get_static_file, AssetType,
};
use libretunes::util::require_auth::require_auth_middleware;
use libretunes::util::fileserv::{file_and_error_handler, get_asset_file, get_static_file, AssetType};
use axum_login::tower_sessions::SessionManagerLayer;
use log::*;
use tower_sessions_redis_store::fred;
use tower_sessions_redis_store::{fred::prelude::*, RedisStore};
use axum_login::AuthManagerLayerBuilder;
use libretunes::util::auth_backend::AuthBackend;
use log::*;
flexi_logger::Logger::try_with_env_or_str("debug").unwrap().format(flexi_logger::opt_format).start().unwrap();
flexi_logger::Logger::try_with_env_or_str("debug")
.unwrap()
.format(flexi_logger::opt_format)
.start()
.unwrap();
info!("\n{}", include_str!("../ascii_art.txt"));
info!("Starting Leptos server...");
@ -37,9 +43,13 @@ async fn main() {
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis_config = fred::types::config::Config::from_url(&redis_url)
.unwrap_or_else(|_| panic!("Unable to parse Redis URL: {redis_url}"));
let redis_pool = fred::clients::Pool::new(redis_config, None, None, None, 1).expect("Unable to create Redis pool");
let redis_pool = fred::clients::Pool::new(redis_config, None, None, None, 1)
.expect("Unable to create Redis pool");
redis_pool.connect();
redis_pool.wait_for_connect().await.expect("Unable to connect to Redis");
redis_pool
.wait_for_connect()
.await
.expect("Unable to connect to Redis");
let session_store = RedisStore::new(redis_pool);
let session_layer = SessionManagerLayer::new(session_store);
@ -58,19 +68,29 @@ async fn main() {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.route("/assets/audio/:song", get(|Path(song) : Path<String>| get_asset_file(song, AssetType::Audio)))
.route("/assets/images/:image", get(|Path(image) : Path<String>| get_asset_file(image, AssetType::Image)))
.route(
"/assets/audio/:song",
get(|Path(song): Path<String>| get_asset_file(song, AssetType::Audio)),
)
.route(
"/assets/images/:image",
get(|Path(image): Path<String>| get_asset_file(image, AssetType::Image)),
)
.route("/assets/*uri", get(|uri| get_static_file(uri, "")))
.layer(from_fn(require_auth_middleware))
.layer(auth_layer)
.fallback(file_and_error_handler)
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap_or_else(|_| panic!("Could not bind to {}", &addr));
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|_| panic!("Could not bind to {}", &addr));
info!("Listening on http://{}", &addr);
axum::serve(listener, app.into_make_service()).await.expect("Server failed");
axum::serve(listener, app.into_make_service())
.await
.expect("Server failed");
}
#[cfg(not(feature = "ssr"))]

View File

@ -4,24 +4,27 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
}
/// Model for an album
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
#[cfg_attr(
feature = "ssr",
derive(Queryable, Selectable, Insertable, Identifiable)
)]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::albums))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Album {
/// A unique id for the album
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<NaiveDate>,
/// The path to the album's image file
pub image_path: Option<String>,
/// A unique id for the album
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<NaiveDate>,
/// The path to the album's image file
pub image_path: Option<String>,
}

View File

@ -3,42 +3,45 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
}
/// Model for an artist
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
#[cfg_attr(
feature = "ssr",
derive(Queryable, Selectable, Insertable, Identifiable)
)]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Artist {
/// A unique id for the artist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The artist's name
pub name: String,
/// A unique id for the artist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The artist's name
pub name: String,
}
impl Artist {
/// Display a list of artists as a string.
///
/// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2].
/// For three or more artists, displays [artist1], [artist2], & [artist3].
pub fn display_list(artists: &[Artist]) -> String {
let mut artist_list = String::new();
/// Display a list of artists as a string.
///
/// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2].
/// For three or more artists, displays [artist1], [artist2], & [artist3].
pub fn display_list(artists: &[Artist]) -> String {
let mut artist_list = String::new();
for (i, artist) in artists.iter().enumerate() {
if i == 0 {
artist_list.push_str(&artist.name);
} else if i == artists.len() - 1 {
artist_list.push_str(&format!(" & {}", artist.name));
} else {
artist_list.push_str(&format!(", {}", artist.name));
}
}
for (i, artist) in artists.iter().enumerate() {
if i == 0 {
artist_list.push_str(&artist.name);
} else if i == artists.len() - 1 {
artist_list.push_str(&format!(" & {}", artist.name));
} else {
artist_list.push_str(&format!(", {}", artist.name));
}
}
artist_list
}
artist_list
}
}

View File

@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
}
/// Model for a history entry
@ -15,13 +15,13 @@ cfg_if! {
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct HistoryEntry {
/// A unique id for the history entry
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The id of the user who listened to the song
pub user_id: i32,
/// The date the song was listened to
pub date: NaiveDateTime,
/// The id of the song that was listened to
pub song_id: i32,
/// A unique id for the history entry
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The id of the user who listened to the song
pub user_id: i32,
/// The date the song was listened to
pub date: NaiveDateTime,
/// The id of the song that was listened to
pub song_id: i32,
}

View File

@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
}
/// Model for a playlist
@ -15,17 +15,17 @@ cfg_if! {
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Playlist {
/// A unique id for the playlist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The time the playlist was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// The time the playlist was last updated
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub updated_at: Option<NaiveDateTime>,
/// The id of the user who owns the playlist
pub owner_id: i32,
/// The name of the playlist
pub name: String,
/// A unique id for the playlist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The time the playlist was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// The time the playlist was last updated
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub updated_at: Option<NaiveDateTime>,
/// The id of the user who owns the playlist
pub owner_id: i32,
/// The name of the playlist
pub name: String,
}

View File

@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
}
}
/// Model for a song
@ -15,24 +15,24 @@ cfg_if! {
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Clone, Serialize, Deserialize)]
pub struct Song {
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
/// The date the song was added to the database
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub added_date: Option<NaiveDateTime>,
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
/// The date the song was added to the database
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub added_date: Option<NaiveDateTime>,
}

View File

@ -4,12 +4,12 @@ use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::*;
use std::error::Error;
use crate::models::backend::{Song, HistoryEntry};
}
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::*;
use std::error::Error;
use crate::models::backend::{Song, HistoryEntry};
}
}
// Model for a "User", used for querying the database
@ -21,227 +21,296 @@ cfg_if! {
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
/// A unique id for the user
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
// #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released
pub id: Option<i32>,
/// The user's username
pub username: String,
/// The user's email
pub email: String,
/// The user's password, stored as a hash
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
pub password: Option<String>,
/// The time the user was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// Whether the user is an admin
pub admin: bool,
/// A unique id for the user
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
// #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released
pub id: Option<i32>,
/// The user's username
pub username: String,
/// The user's email
pub email: String,
/// The user's password, stored as a hash
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
pub password: Option<String>,
/// The time the user was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// Whether the user is an admin
pub admin: bool,
}
impl User {
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
/// A result indicating success with a vector of history entries, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history(&self, limit: Option<i64>, conn: &mut PgPooledConn) ->
Result<Vec<HistoryEntry>, Box<dyn Error>> {
use crate::schema::song_history::dsl::*;
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
/// A result indicating success with a vector of history entries, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history(
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<HistoryEntry>, Box<dyn Error>> {
use crate::schema::song_history::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
let my_id = self
.id
.ok_or("Artist id must be present (Some) to get history")?;
let my_history =
if let Some(limit) = limit {
song_history
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.load(conn)?
} else {
song_history
.filter(user_id.eq(my_id))
.load(conn)?
};
let my_history = if let Some(limit) = limit {
song_history
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.load(conn)?
} else {
song_history.filter(user_id.eq(my_id)).load(conn)?
};
Ok(my_history)
}
Ok(my_history)
}
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
/// A result indicating success with a vector of listen dates and songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history_songs(&self, limit: Option<i64>, conn: &mut PgPooledConn) ->
Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_history::dsl::*;
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
/// A result indicating success with a vector of listen dates and songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history_songs(
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
use crate::schema::song_history::dsl::*;
use crate::schema::songs::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
let my_id = self
.id
.ok_or("Artist id must be present (Some) to get history")?;
let my_history =
if let Some(limit) = limit {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)?
};
let my_history = if let Some(limit) = limit {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)?
};
Ok(my_history)
}
Ok(my_history)
}
/// Add a song to this user's history in the database
///
/// The date of the history entry will be the current time
/// The `id` field of this user must be present (Some) to add history
///
/// # Arguments
///
/// * `song_id` - The id of the song to add to this user's history
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `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>> {
use crate::schema::song_history;
/// Add a song to this user's history in the database
///
/// The date of the history entry will be the current time
/// The `id` field of this user must be present (Some) to add history
///
/// # Arguments
///
/// * `song_id` - The id of the song to add to this user's history
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `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>> {
use crate::schema::song_history;
let my_id = self.id.ok_or("Artist id must be present (Some) to add history")?;
let my_id = self
.id
.ok_or("Artist id must be present (Some) to add history")?;
diesel::insert_into(song_history::table)
.values((song_history::user_id.eq(my_id), song_history::song_id.eq(song_id)))
.execute(conn)?;
diesel::insert_into(song_history::table)
.values((
song_history::user_id.eq(my_id),
song_history::song_id.eq(song_id),
))
.execute(conn)?;
Ok(())
}
Ok(())
}
/// Like or unlike a song for this user
/// If likeing a song, remove dislike if it exists
#[cfg(feature = "ssr")]
pub async fn set_like_song(&self, song_id: i32, like: bool, conn: &mut PgPooledConn) ->
Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
/// Like or unlike a song for this user
/// If likeing a song, remove dislike if it exists
#[cfg(feature = "ssr")]
pub async fn set_like_song(
&self,
song_id: i32,
like: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
use crate::schema::song_likes;
use crate::schema::song_dislikes;
use crate::schema::song_dislikes;
use crate::schema::song_likes;
let my_id = self.id.ok_or("User id must be present (Some) to like/un-like a song")?;
let my_id = self
.id
.ok_or("User id must be present (Some) to like/un-like a song")?;
if like {
diesel::insert_into(song_likes::table)
.values((song_likes::song_id.eq(song_id), song_likes::user_id.eq(my_id)))
.execute(conn)?;
if like {
diesel::insert_into(song_likes::table)
.values((
song_likes::song_id.eq(song_id),
song_likes::user_id.eq(my_id),
))
.execute(conn)?;
// Remove dislike if it exists
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
.and(song_dislikes::user_id.eq(my_id))))
.execute(conn)?;
} else {
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id))))
.execute(conn)?;
}
// Remove dislike if it exists
diesel::delete(
song_dislikes::table.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(my_id)),
),
)
.execute(conn)?;
} else {
diesel::delete(
song_likes::table.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(my_id)),
),
)
.execute(conn)?;
}
Ok(())
}
Ok(())
}
/// Get the like status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_like_song(&self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_likes;
/// Get the like status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_like_song(
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_likes;
let my_id = self.id.ok_or("User id must be present (Some) to get like status of a song")?;
let my_id = self
.id
.ok_or("User id must be present (Some) to get like status of a song")?;
let like = song_likes::table
.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id)))
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
let like = song_likes::table
.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(my_id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
Ok(like)
}
Ok(like)
}
/// Dislike or remove dislike from a song for this user
/// If disliking a song, remove like if it exists
#[cfg(feature = "ssr")]
pub async fn set_dislike_song(&self, song_id: i32, dislike: bool, conn: &mut PgPooledConn) ->
Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
/// Dislike or remove dislike from a song for this user
/// If disliking a song, remove like if it exists
#[cfg(feature = "ssr")]
pub async fn set_dislike_song(
&self,
song_id: i32,
dislike: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
use crate::schema::song_likes;
use crate::schema::song_dislikes;
use crate::schema::song_dislikes;
use crate::schema::song_likes;
let my_id = self.id.ok_or("User id must be present (Some) to dislike/un-dislike a song")?;
let my_id = self
.id
.ok_or("User id must be present (Some) to dislike/un-dislike a song")?;
if dislike {
diesel::insert_into(song_dislikes::table)
.values((song_dislikes::song_id.eq(song_id), song_dislikes::user_id.eq(my_id)))
.execute(conn)?;
if dislike {
diesel::insert_into(song_dislikes::table)
.values((
song_dislikes::song_id.eq(song_id),
song_dislikes::user_id.eq(my_id),
))
.execute(conn)?;
// Remove like if it exists
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id)
.and(song_likes::user_id.eq(my_id))))
.execute(conn)?;
} else {
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
.and(song_dislikes::user_id.eq(my_id))))
.execute(conn)?;
}
// Remove like if it exists
diesel::delete(
song_likes::table.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(my_id)),
),
)
.execute(conn)?;
} else {
diesel::delete(
song_dislikes::table.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(my_id)),
),
)
.execute(conn)?;
}
Ok(())
}
Ok(())
}
/// Get the dislike status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_dislike_song(&self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_dislikes;
/// Get the dislike status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_dislike_song(
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_dislikes;
let my_id = self.id.ok_or("User id must be present (Some) to get dislike status of a song")?;
let my_id = self
.id
.ok_or("User id must be present (Some) to get dislike status of a song")?;
let dislike = song_dislikes::table
.filter(song_dislikes::song_id.eq(song_id).and(song_dislikes::user_id.eq(my_id)))
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
let dislike = song_dislikes::table
.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(my_id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
Ok(dislike)
}
Ok(dislike)
}
}

View File

@ -1,35 +1,35 @@
use crate::models::backend::Artist;
use crate::components::dashboard_tile::DashboardTile;
use serde::{Serialize, Deserialize};
use crate::models::backend::Artist;
use serde::{Deserialize, Serialize};
use chrono::NaiveDate;
/// Holds information about an album
///
///
/// Intended to be used in the front-end
#[derive(Serialize, Deserialize, Clone)]
pub struct Album {
/// Album id
pub id: i32,
/// Album title
pub title: String,
/// Album artists
pub artists: Vec<Artist>,
/// Album release date
pub release_date: Option<NaiveDate>,
/// Path to album image, relative to the root of the web server.
/// For example, `"/assets/images/Album.jpg"`
pub image_path: String,
/// Album id
pub id: i32,
/// Album title
pub title: String,
/// Album artists
pub artists: Vec<Artist>,
/// Album release date
pub release_date: Option<NaiveDate>,
/// Path to album image, relative to the root of the web server.
/// For example, `"/assets/images/Album.jpg"`
pub image_path: String,
}
impl From<Album> for DashboardTile {
fn from(val: Album) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/album/{}", val.id).into(),
description: Some(format!("Album • {}", Artist::display_list(&val.artists)).into()),
}
}
fn from(val: Album) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/album/{}", val.id).into(),
description: Some(format!("Album • {}", Artist::display_list(&val.artists)).into()),
}
}
}

View File

@ -1,27 +1,27 @@
use crate::components::dashboard_tile::DashboardTile;
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
/// Holds information about an artist
///
///
/// Intended to be used in the front-end
#[derive(Clone, Serialize, Deserialize)]
pub struct Artist {
/// Artist id
pub id: i32,
/// Artist name
pub name: String,
/// Path to artist image, relative to the root of the web server.
/// For example, `"/assets/images/Artist.jpg"`
pub image_path: String,
/// Artist id
pub id: i32,
/// Artist name
pub name: String,
/// Path to artist image, relative to the root of the web server.
/// For example, `"/assets/images/Artist.jpg"`
pub image_path: String,
}
impl From<Artist> for DashboardTile {
fn from(val: Artist) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.name.into(),
link: format!("/artist/{}", val.id).into(),
description: Some("Artist".into()),
}
}
fn from(val: Artist) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.name.into(),
link: format!("/artist/{}", val.id).into(),
description: Some("Artist".into()),
}
}
}

View File

@ -1,46 +1,46 @@
use leptos::prelude::*;
use web_sys::HtmlAudioElement;
use leptos::html::Audio;
use leptos::prelude::*;
use std::collections::VecDeque;
use web_sys::HtmlAudioElement;
use crate::models::frontend;
/// Represents the global state of the audio player feature of `LibreTunes`
pub struct PlayStatus {
/// Whether or not the audio player is currently playing
/// Whether or not the audio player is currently playing
pub playing: bool,
/// Whether or not the queue is open
pub queue_open: bool,
/// A reference to the HTML audio element
/// Whether or not the queue is open
pub queue_open: bool,
/// A reference to the HTML audio element
pub audio_player: Option<NodeRef<Audio>>,
/// A queue of songs that have been played, ordered from oldest to newest
/// A queue of songs that have been played, ordered from oldest to newest
pub history: VecDeque<frontend::Song>,
/// A queue of songs that have yet to be played, ordered from next up to last
/// A queue of songs that have yet to be played, ordered from next up to last
pub queue: VecDeque<frontend::Song>,
}
impl PlayStatus {
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
///
/// Instead of:
/// ```
/// use leptos::prelude::*;
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.audio_player {
/// if let Some(audio) = audio.get() {
/// let _ = audio.play();
/// }
/// }
/// ```
///
/// You can do:
/// ```
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.get_audio() {
/// let _ = audio.play();
/// }
/// ```
pub fn get_audio(&self) -> Option<HtmlAudioElement> {
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
///
/// Instead of:
/// ```
/// use leptos::prelude::*;
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.audio_player {
/// if let Some(audio) = audio.get() {
/// let _ = audio.play();
/// }
/// }
/// ```
///
/// You can do:
/// ```
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.get_audio() {
/// let _ = audio.play();
/// }
/// ```
pub fn get_audio(&self) -> Option<HtmlAudioElement> {
if let Some(audio) = &self.audio_player {
if let Some(audio) = audio.get() {
return Some(audio);
@ -52,11 +52,11 @@ impl PlayStatus {
}
impl Default for PlayStatus {
/// Creates a paused `PlayStatus` with no audio player, no progress update handle, and empty queue/history
/// Creates a paused `PlayStatus` with no audio player, no progress update handle, and empty queue/history
fn default() -> Self {
Self {
playing: false,
queue_open: false,
queue_open: false,
audio_player: None,
history: VecDeque::new(),
queue: VecDeque::new(),

View File

@ -1,79 +1,84 @@
use crate::models::backend::{self, Album, Artist};
use crate::components::dashboard_tile::DashboardTile;
use crate::models::backend::{self, Album, Artist};
use serde::{Serialize, Deserialize};
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
/// Holds information about a song
///
///
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
#[derive(Serialize, Deserialize, Clone)]
pub struct Song {
/// Song id
pub id: i32,
/// Song name
pub title: String,
/// Song artists
pub artists: Vec<Artist>,
/// Song album
pub album: Option<Album>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
/// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"`
pub image_path: String,
/// Whether the song is liked by the user
pub like_dislike: Option<(bool, bool)>,
/// The date the song was added to the database
pub added_date: NaiveDateTime,
/// Song id
pub id: i32,
/// Song name
pub title: String,
/// Song artists
pub artists: Vec<Artist>,
/// Song album
pub album: Option<Album>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
/// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"`
pub image_path: String,
/// Whether the song is liked by the user
pub like_dislike: Option<(bool, bool)>,
/// The date the song was added to the database
pub added_date: NaiveDateTime,
}
impl TryInto<backend::Song> for Song {
type Error = Box<dyn std::error::Error>;
type Error = Box<dyn std::error::Error>;
/// Convert a `SongData` object into a Song object
///
/// The SongData/Song conversions are also not truly reversible,
/// due to the way the `image_path` data is handled.
fn try_into(self) -> Result<backend::Song, Self::Error> {
Ok(backend::Song {
id: Some(self.id),
title: self.title,
album_id: self.album.map(|album|
album.id.ok_or("Album id must be present (Some) to convert to Song")).transpose()?,
track: self.track,
duration: self.duration,
release_date: self.release_date,
storage_path: self.song_path,
/// Convert a `SongData` object into a Song object
///
/// The SongData/Song conversions are also not truly reversible,
/// due to the way the `image_path` data is handled.
fn try_into(self) -> Result<backend::Song, Self::Error> {
Ok(backend::Song {
id: Some(self.id),
title: self.title,
album_id: self
.album
.map(|album| {
album
.id
.ok_or("Album id must be present (Some) to convert to Song")
})
.transpose()?,
track: self.track,
duration: self.duration,
release_date: self.release_date,
storage_path: self.song_path,
// Note that if the source of the image_path was the album, the image_path
// will be set to the album's image_path instead of None
image_path: if self.image_path == "/assets/images/placeholder.jpg" {
None
} else {
Some(self.image_path)
},
// Note that if the source of the image_path was the album, the image_path
// will be set to the album's image_path instead of None
image_path: if self.image_path == "/assets/images/placeholder.jpg" {
None
} else {
Some(self.image_path)
},
added_date: Some(self.added_date),
})
}
added_date: Some(self.added_date),
})
}
}
impl From<Song> for DashboardTile {
fn from(val: Song) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/song/{}", val.id).into(),
description: Some(format!("Song • {}", Artist::display_list(&val.artists)).into()),
}
}
fn from(val: Song) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/song/{}", val.id).into(),
description: Some(format!("Song • {}", Artist::display_list(&val.artists)).into()),
}
}
}

View File

@ -1,12 +1,12 @@
use leptos::prelude::*;
use crate::api::album::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::models::frontend;
use leptos::either::*;
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::components::song_list::*;
use crate::components::loading::*;
use crate::components::error::*;
use crate::api::album::*;
use crate::models::frontend;
#[component]
pub fn AlbumPage() -> impl IntoView {
@ -78,15 +78,16 @@ fn AlbumIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
#[component]
fn AlbumInfo(album: frontend::Album) -> impl IntoView {
view! {
<div class="flex">
<img class="w-70 h-70 p-5" src={album.image_path} alt="Album Cover" />
<div class="self-center">
<h1 class="text-4xl">{album.title}</h1>
view! {
<div class="flex">
<img class="w-70 h-70 p-5" src={album.image_path} alt="Album Cover" />
<div class="self-center">
<h1 class="text-4xl">{album.title}</h1>
<SongArtists artists=album.artists />
</div>
</div>
}.into_view()
</div>
</div>
}
.into_view()
}
#[component]

View File

@ -1,13 +1,13 @@
use leptos::prelude::*;
use leptos::either::*;
use leptos_router::hooks::use_params_map;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::models::backend::Artist;
use crate::components::loading::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::api::artists::*;
@ -45,9 +45,7 @@ pub fn ArtistPage() -> impl IntoView {
#[component]
fn ArtistIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let artist_info = Resource::new(move || id.get(), move |id| {
get_artist_by_id(id)
});
let artist_info = Resource::new(move || id.get(), move |id| get_artist_by_id(id));
let show_details = RwSignal::new(false);
@ -102,21 +100,27 @@ fn ArtistProfile(artist: Artist) -> impl IntoView {
#[component]
fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
let top_songs = Resource::new(move || artist_id.get(), |artist_id| async move {
let top_songs = top_songs_by_artist(artist_id, Some(10)).await;
let top_songs = Resource::new(
move || artist_id.get(),
|artist_id| async move {
let top_songs = top_songs_by_artist(artist_id, Some(10)).await;
top_songs.map(|top_songs| {
top_songs.into_iter().map(|(song, plays)| {
let plays = if plays == 1 {
"1 play".to_string()
} else {
format!("{plays} plays")
};
top_songs.map(|top_songs| {
top_songs
.into_iter()
.map(|(song, plays)| {
let plays = if plays == 1 {
"1 play".to_string()
} else {
format!("{plays} plays")
};
(song, plays)
}).collect::<Vec<_>>()
})
});
(song, plays)
})
.collect::<Vec<_>>()
})
},
);
view! {
<h2 class="text-xl font-bold">"Top Songs"</h2>
@ -146,13 +150,14 @@ fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
fn AlbumsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
use crate::components::dashboard_row::*;
let albums = Resource::new(move || artist_id.get(), |artist_id| async move {
let albums = albums_by_artist(artist_id, None).await;
let albums = Resource::new(
move || artist_id.get(),
|artist_id| async move {
let albums = albums_by_artist(artist_id, None).await;
albums.map(|albums| {
albums.into_iter().collect::<Vec<_>>()
})
});
albums.map(|albums| albums.into_iter().collect::<Vec<_>>())
},
);
view! {
<Transition

View File

@ -1,12 +1,12 @@
use crate::api::auth::login;
use crate::api::users::UserCredentials;
use crate::components::fancy_input::*;
use crate::components::loading::Loading;
use crate::util::state::GlobalState;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos::task::spawn_local;
use crate::api::users::UserCredentials;
use crate::components::loading::Loading;
use crate::components::fancy_input::*;
use leptos_icons::*;
#[component]
pub fn Login() -> impl IntoView {
@ -29,7 +29,7 @@ pub fn Login() -> impl IntoView {
};
let user = GlobalState::logged_in_user();
let login_result = login(user_credentials).await;
if let Err(err) = login_result {
// Handle the error here, e.g., log it or display to the user

View File

@ -1,8 +1,8 @@
pub mod login;
pub mod signup;
pub mod profile;
pub mod album;
pub mod artist;
pub mod song;
pub mod search;
pub mod dashboard;
pub mod login;
pub mod profile;
pub mod search;
pub mod signup;
pub mod song;

View File

@ -1,18 +1,18 @@
use leptos::prelude::*;
use leptos::either::*;
use leptos_router::hooks::use_params_map;
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::song_list::*;
use crate::components::loading::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::api::profile::*;
use crate::models::backend::User;
use crate::api::users::get_user_by_id;
use crate::models::backend::User;
use crate::util::state::GlobalState;
/// Duration in seconds backwards from now to aggregate history data for
@ -30,286 +30,310 @@ const TOP_ARTISTS_COUNT: i64 = 10;
/// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path
#[component]
pub fn Profile() -> impl IntoView {
let params = use_params_map();
let params = use_params_map();
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
None => {
// No id specified, show the current user's profile
EitherOf3::A(view! { <OwnProfile /> })
},
Some(Ok(id)) => {
// Id specified, get the user and show their profile
EitherOf3::B(view! { <UserIdProfile id /> })
},
Some(Err(e)) => {
// Invalid id, return an error
EitherOf3::C(view! {
<Error<String>
title="Invalid User ID"
error=e.to_string()
/>
})
}
}
})}
}
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
None => {
// No id specified, show the current user's profile
EitherOf3::A(view! { <OwnProfile /> })
},
Some(Ok(id)) => {
// Id specified, get the user and show their profile
EitherOf3::B(view! { <UserIdProfile id /> })
},
Some(Err(e)) => {
// Invalid id, return an error
EitherOf3::C(view! {
<Error<String>
title="Invalid User ID"
error=e.to_string()
/>
})
}
}
})}
}
}
/// Show the logged in user's profile
#[component]
fn OwnProfile() -> impl IntoView {
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || GlobalState::logged_in_user().get().map(|user| {
match user {
Some(user) => {
let user_id = user.id.unwrap();
Either::Left(view! {
<UserProfile user />
<TopSongs user_id={user_id} />
<RecentSongs user_id={user_id} />
<TopArtists user_id={user_id} />
})
},
None => Either::Right(view! {
<Error<String>
title="Not Logged In"
message="You must be logged in to view your profile"
/>
}),
}
})}
</Transition>
}
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || GlobalState::logged_in_user().get().map(|user| {
match user {
Some(user) => {
let user_id = user.id.unwrap();
Either::Left(view! {
<UserProfile user />
<TopSongs user_id={user_id} />
<RecentSongs user_id={user_id} />
<TopArtists user_id={user_id} />
})
},
None => Either::Right(view! {
<Error<String>
title="Not Logged In"
message="You must be logged in to view your profile"
/>
}),
}
})}
</Transition>
}
}
/// Show a user's profile by ID
#[component]
fn UserIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let user_info = Resource::new(move || id.get(), move |id| {
get_user_by_id(id)
});
let user_info = Resource::new(move || id.get(), move |id| get_user_by_id(id));
// Show the details if the user is found
let show_details = RwSignal::new(false);
// Show the details if the user is found
let show_details = RwSignal::new(false);
view!{
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || user_info.get().map(|user| {
match user {
Ok(Some(user)) => {
show_details.set(true);
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || user_info.get().map(|user| {
match user {
Ok(Some(user)) => {
show_details.set(true);
EitherOf3::A(view! { <UserProfile user /> })
},
Ok(None) => {
show_details.set(false);
EitherOf3::A(view! { <UserProfile user /> })
},
Ok(None) => {
show_details.set(false);
EitherOf3::B(view! {
<Error<String>
title="User Not Found"
message=format!("User with ID {} not found", id.get())
/>
})
},
Err(error) => {
show_details.set(false);
EitherOf3::B(view! {
<Error<String>
title="User Not Found"
message=format!("User with ID {} not found", id.get())
/>
})
},
Err(error) => {
show_details.set(false);
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting User"
error
/>
})
}
}
})}
</Transition>
<div hidden={move || !show_details.get()}>
<TopSongs user_id={id} />
<RecentSongs user_id={id} />
<TopArtists user_id={id} />
</div>
}
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting User"
error
/>
})
}
}
})}
</Transition>
<div hidden={move || !show_details.get()}>
<TopSongs user_id={id} />
<RecentSongs user_id={id} />
<TopArtists user_id={id} />
</div>
}
}
/// Show a profile for a User object
#[component]
fn UserProfile(user: User) -> impl IntoView {
let user_id = user.id.unwrap();
let profile_image_path = format!("/assets/images/profile/{user_id}.webp");
let user_id = user.id.unwrap();
let profile_image_path = format!("/assets/images/profile/{user_id}.webp");
view! {
<div class="flex">
<object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
<Icon icon={icondata::CgProfile} width="100" height="100" />
</object>
<h1 class="text-4xl self-center">{user.username}</h1>
</div>
<p class="m-2">
{user.email}
{
user.created_at.map(|created_at| {
format!(" • Joined {}", created_at.format("%B %Y"))
})
}
{
if user.admin {
" • Admin"
} else {
""
}
}
</p>
}
view! {
<div class="flex">
<object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
<Icon icon={icondata::CgProfile} width="100" height="100" />
</object>
<h1 class="text-4xl self-center">{user.username}</h1>
</div>
<p class="m-2">
{user.email}
{
user.created_at.map(|created_at| {
format!(" • Joined {}", created_at.format("%B %Y"))
})
}
{
if user.admin {
" • Admin"
} else {
""
}
}
</p>
}
}
/// Show a list of top songs for a user
#[component]
fn TopSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let top_songs = Resource::new(move || user_id.get(), |user_id| async move {
use chrono::{Local, Duration};
let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS);
let top_songs = top_songs(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_SONGS_COUNT)).await;
let top_songs = Resource::new(
move || user_id.get(),
|user_id| async move {
use chrono::{Duration, Local};
let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS);
let top_songs = top_songs(
user_id,
start.naive_utc(),
now.naive_utc(),
Some(TOP_SONGS_COUNT),
)
.await;
top_songs.map(|top_songs| {
top_songs.into_iter().map(|(plays, song)| {
let plays = if plays == 1 {
format!("{plays} Play")
} else {
format!("{plays} Plays")
};
top_songs.map(|top_songs| {
top_songs
.into_iter()
.map(|(plays, song)| {
let plays = if plays == 1 {
format!("{plays} Play")
} else {
format!("{plays} Plays")
};
(song, plays)
}).collect::<Vec<_>>()
})
});
(song, plays)
})
.collect::<Vec<_>>()
})
},
);
view! {
view! {
<h2 class="text-xl font-bold">{format!("Top Songs {HISTORY_MESSAGE}")}</h2>
<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| {
view! {
<SongListExtra songs=top_songs />
}
})
})
}
</ErrorBoundary>
</Transition>
}
<h2 class="text-xl font-bold">{format!("Top Songs {HISTORY_MESSAGE}")}</h2>
<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| {
view! {
<SongListExtra songs=top_songs />
}
})
})
}
</ErrorBoundary>
</Transition>
}
}
/// Show a list of recently played songs for a user
#[component]
fn RecentSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let recent_songs = Resource::new(move || user_id.get(), |user_id| async move {
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await;
let recent_songs = Resource::new(
move || user_id.get(),
|user_id| async move {
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await;
recent_songs.map(|recent_songs| {
recent_songs.into_iter().map(|(_date, song)| {
song
}).collect::<Vec<_>>()
})
});
recent_songs.map(|recent_songs| {
recent_songs
.into_iter()
.map(|(_date, song)| song)
.collect::<Vec<_>>()
})
},
);
view! {
<h2 class="text-xl font-bold">"Recently Played"</h2>
<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| {
recent_songs.map(|recent_songs| {
view! {
<SongList songs=recent_songs />
}
})
})
}
</ErrorBoundary>
</Transition>
}
view! {
<h2 class="text-xl font-bold">"Recently Played"</h2>
<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| {
recent_songs.map(|recent_songs| {
view! {
<SongList songs=recent_songs />
}
})
})
}
</ErrorBoundary>
</Transition>
}
}
/// Show a list of top artists for a user
#[component]
fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let top_artists = Resource::new(move || user_id.get(), |user_id| async move {
use chrono::{Local, Duration};
let top_artists = Resource::new(
move || user_id.get(),
|user_id| async move {
use chrono::{Duration, Local};
let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS);
let top_artists = top_artists(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_ARTISTS_COUNT)).await;
let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS);
let top_artists = top_artists(
user_id,
start.naive_utc(),
now.naive_utc(),
Some(TOP_ARTISTS_COUNT),
)
.await;
top_artists.map(|top_artists| {
top_artists.into_iter().map(|(_plays, artist)| {
artist
}).collect::<Vec<_>>()
})
});
top_artists.map(|top_artists| {
top_artists
.into_iter()
.map(|(_plays, artist)| artist)
.collect::<Vec<_>>()
})
},
);
view! {
<Transition
fallback=move || view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
<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| {
let tiles = top_artists.into_iter().map(|artist| {
artist.into()
}).collect::<Vec<_>>();
view! {
<Transition
fallback=move || view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
<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| {
let tiles = top_artists.into_iter().map(|artist| {
artist.into()
}).collect::<Vec<_>>();
view! {
<DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
}
})
})
}
</ErrorBoundary>
</Transition>
}
view! {
<DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
}
})
})
}
</ErrorBoundary>
</Transition>
}
}

View File

@ -7,4 +7,4 @@ pub fn Search() -> impl IntoView {
<h1>Searching...</h1>
</div>
}
}
}

View File

@ -1,12 +1,12 @@
use crate::api::auth::signup;
use crate::components::fancy_input::*;
use crate::components::loading::Loading;
use crate::models::backend::User;
use crate::util::state::GlobalState;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos::task::spawn_local;
use crate::components::loading::Loading;
use crate::components::fancy_input::*;
use leptos_icons::*;
#[component]
pub fn Signup() -> impl IntoView {

View File

@ -1,19 +1,19 @@
use leptos::prelude::*;
use leptos::either::*;
use leptos_router::hooks::use_params_map;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::api::songs;
use crate::components::loading::*;
use crate::components::error::*;
use crate::components::song_list::*;
use crate::api::songs::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::models::frontend;
use crate::util::state::GlobalState;
use std::rc::Rc;
use std::borrow::Borrow;
use std::rc::Rc;
#[component]
pub fn SongPage() -> impl IntoView {
@ -48,9 +48,7 @@ pub fn SongPage() -> impl IntoView {
#[component]
fn SongDetails(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let song_info = Resource::new(move || id.get(), move |id| {
get_song_by_id(id)
});
let song_info = Resource::new(move || id.get(), move |id| get_song_by_id(id));
view! {
<Transition
@ -98,7 +96,8 @@ fn SongOverview(song: frontend::Song) -> impl IntoView {
Effect::new(move |_| {
GlobalState::play_status().with(|status| {
playing.set(status.queue.front().map(|song| song.id) == Some(song.id) && status.playing);
playing
.set(status.queue.front().map(|song| song.id) == Some(song.id) && status.playing);
});
});
@ -114,7 +113,9 @@ fn SongOverview(song: frontend::Song) -> impl IntoView {
}
status.queue.clear();
status.queue.push_front(<Rc<frontend::Song> as Borrow<frontend::Song>>::borrow(&song_rc).clone());
status.queue.push_front(
<Rc<frontend::Song> as Borrow<frontend::Song>>::borrow(&song_rc).clone(),
);
status.playing = true;
}
});

View File

@ -1,37 +1,44 @@
use std::fs::File;
use symphonia::core::codecs::CodecType;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use std::fs::File;
/// 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>> {
let source_stream = MediaSourceStream::new(Box::new(file), Default::default());
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 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 reader = probe.format;
let probe = symphonia::default::get_probe().format(
&hint,
source_stream,
&format_opts,
&metadata_opts,
)?;
let reader = probe.format;
if reader.tracks().len() != 1 {
return Err(format!("Expected 1 track, found {}", reader.tracks().len()).into())
}
if reader.tracks().len() != 1 {
return Err(format!("Expected 1 track, found {}", reader.tracks().len()).into());
}
let track = &reader.tracks()[0];
let track = &reader.tracks()[0];
let time_base = track.codec_params.time_base.ok_or("Missing time base")?;
let duration = track.codec_params.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.ok_or("Missing number of frames")?;
let time_base = track.codec_params.time_base.ok_or("Missing time base")?;
let duration = track
.codec_params
.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.ok_or("Missing number of frames")?;
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")?;
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((track.codec_params.codec, duration))
Ok((track.codec_params.codec, duration))
}

View File

@ -1,24 +1,23 @@
use axum_login::{AuthnBackend, AuthUser, UserId};
use crate::api::users::UserCredentials;
use axum_login::{AuthUser, AuthnBackend, UserId};
use leptos::server_fn::error::ServerFnErrorErr;
use crate::models::backend::User;
use async_trait::async_trait;
impl AuthUser for User {
type Id = i32;
type Id = i32;
// TODO: Ideally, we shouldn't have to unwrap here
// TODO: Ideally, we shouldn't have to unwrap here
fn id(&self) -> Self::Id {
self.id.unwrap()
}
fn id(&self) -> Self::Id {
self.id.unwrap()
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_ref().unwrap().as_bytes()
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_ref().unwrap().as_bytes()
}
}
#[derive(Clone)]
@ -26,17 +25,22 @@ pub struct AuthBackend;
#[async_trait]
impl AuthnBackend for AuthBackend {
type User = User;
type Credentials = UserCredentials;
type Error = ServerFnErrorErr;
type User = User;
type Credentials = UserCredentials;
type Error = ServerFnErrorErr;
async fn authenticate(&self, creds: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::validate_user(creds).await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error validating user: {e}")))
}
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::validate_user(creds)
.await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error validating user: {e}")))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::find_user_by_id(*user_id).await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error getting user: {e}")))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::find_user_by_id(*user_id)
.await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error getting user: {e}")))
}
}

View File

@ -1,19 +1,10 @@
use leptos::logging::log;
use diesel::{pg::PgConnection, r2d2::ConnectionManager, r2d2::Pool, r2d2::PooledConnection};
use lazy_static::lazy_static;
use std::env;
use diesel::{
pg::PgConnection,
r2d2::ConnectionManager,
r2d2::PooledConnection,
r2d2::Pool,
};
use diesel_migrations::{
embed_migrations,
EmbeddedMigrations,
MigrationHarness,
};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
// See https://leward.eu/notes-on-diesel-a-rust-orm/
@ -30,7 +21,7 @@ lazy_static! {
///
/// Uses `DATABASE_URL` environment variable to connect to the database if set,
/// otherwise builds a connection string from other environment variables.
///
///
/// Will panic if either the `DATABASE_URL` or `POSTGRES_HOST` environment variables
/// are not set, or if there is an error creating the pool.
///
@ -94,15 +85,19 @@ fn init_db_pool() -> PgPool {
/// # Returns
/// A pooled connection to the database
pub fn get_db_conn() -> PgPooledConn {
DB_POOL.get().expect("Failed to get a database connection from the pool.")
DB_POOL
.get()
.expect("Failed to get a database connection from the pool.")
}
/// Embedded database migrations into the binary
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
/// Run any pending migrations in the database
/// Always safe to call, as it will only run migrations that have not already been run
pub fn migrate() {
let db_con = &mut get_db_conn();
db_con.run_pending_migrations(DB_MIGRATIONS).expect("Could not run database migrations");
db_con
.run_pending_migrations(DB_MIGRATIONS)
.expect("Could not run database migrations");
}

View File

@ -1,17 +1,21 @@
use crate::app::App;
use axum::response::Response as AxumResponse;
use axum::{
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
response::IntoResponse,
};
use axum::response::Response as AxumResponse;
use leptos::prelude::*;
use std::str::FromStr;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::prelude::*;
use crate::app::App;
use std::str::FromStr;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
@ -24,7 +28,10 @@ pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOption
}
pub async fn get_static_file(uri: Uri, root: &str) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
@ -42,13 +49,20 @@ pub enum AssetType {
Image,
}
pub async fn get_asset_file(filename: String, asset_type: AssetType) -> Result<Response<Body>, (StatusCode, String)> {
pub async fn get_asset_file(
filename: String,
asset_type: AssetType,
) -> Result<Response<Body>, (StatusCode, String)> {
const DEFAULT_AUDIO_PATH: &str = "assets/audio";
const DEFAULT_IMAGE_PATH: &str = "assets/images";
let root = match asset_type {
AssetType::Audio => std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string()),
AssetType::Image => std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string()),
AssetType::Audio => {
std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string())
}
AssetType::Image => {
std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string())
}
};
// Create a Uri from the filename

View File

@ -1,13 +1,13 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
pub mod audio;
pub mod require_auth;
pub mod fileserv;
pub mod database;
pub mod auth_backend;
}
if #[cfg(feature = "ssr")] {
pub mod audio;
pub mod require_auth;
pub mod fileserv;
pub mod database;
pub mod auth_backend;
}
}
pub mod state;

View File

@ -1,7 +1,7 @@
use axum::extract::Request;
use axum::response::Response;
use axum::body::Body;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum_login::AuthSession;
use http::StatusCode;
@ -10,37 +10,50 @@ use crate::util::auth_backend::AuthBackend;
use axum::extract::FromRequestParts;
// Things in pkg/ are allowed automatically. This includes the CSS/JS/WASM files
const ALLOWED_PATHS: [&str; 5] = ["/login", "/signup", "/api/login", "/api/signup", "/favicon.ico"];
const ALLOWED_PATHS: [&str; 5] = [
"/login",
"/signup",
"/api/login",
"/api/signup",
"/favicon.ico",
];
/**
* Middleware to require authentication for all paths except those in `ALLOWED_PATHS`
*
*
* If a user is not authenticated, they will be redirected to the login page
*/
pub async fn require_auth_middleware(req: Request, next: Next) -> Result<Response<Body>, (StatusCode, &'static str)> {
let path = req.uri().path();
pub async fn require_auth_middleware(
req: Request,
next: Next,
) -> Result<Response<Body>, (StatusCode, &'static str)> {
let path = req.uri().path();
if !ALLOWED_PATHS.contains(&path) {
let (mut parts, body) = req.into_parts();
if !ALLOWED_PATHS.contains(&path) {
let (mut parts, body) = req.into_parts();
let auth_session = AuthSession::<AuthBackend>::from_request_parts(&mut parts, &())
.await?;
let auth_session = AuthSession::<AuthBackend>::from_request_parts(&mut parts, &()).await?;
if auth_session.user.is_none() {
let response = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header("Location", "/login")
.body(Body::empty())
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response"))?;
if auth_session.user.is_none() {
let response = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header("Location", "/login")
.body(Body::empty())
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build response",
)
})?;
return Ok(response);
}
return Ok(response);
}
let req = Request::from_parts(parts, body);
let response = next.run(req).await;
Ok(response)
} else {
let response = next.run(req).await;
Ok(response)
}
let req = Request::from_parts(parts, body);
let response = next.run(req).await;
Ok(response)
} else {
let response = next.run(req).await;
Ok(response)
}
}

View File

@ -1,9 +1,9 @@
use leptos::prelude::*;
use leptos::logging::*;
use leptos::prelude::*;
use crate::models::frontend::PlayStatus;
use crate::models::backend::User;
use crate::api::auth::get_logged_in_user;
use crate::models::backend::User;
use crate::models::frontend::PlayStatus;
/// Global front-end state
/// Contains anything frequently needed across multiple components
@ -11,9 +11,9 @@ use crate::api::auth::get_logged_in_user;
/// always return the same instance
#[derive(Clone)]
pub struct GlobalState {
/// A resource that fetches the logged in user
/// This will not automatically refetch, so any login/logout related code
/// should call `refetch` on this resource
/// A resource that fetches the logged in user
/// This will not automatically refetch, so any login/logout related code
/// should call `refetch` on this resource
pub logged_in_user: Resource<Option<User>>,
/// The current play status
@ -24,14 +24,18 @@ impl GlobalState {
pub fn new() -> Self {
let play_status = RwSignal::new(PlayStatus::default());
let logged_in_user = Resource::new(|| (), |_| async {
get_logged_in_user().await
.inspect_err(|e| {
error!("Error getting logged in user: {:?}", e);
})
.ok()
.flatten()
});
let logged_in_user = Resource::new(
|| (),
|_| async {
get_logged_in_user()
.await
.inspect_err(|e| {
error!("Error getting logged in user: {:?}", e);
})
.ok()
.flatten()
},
);
Self {
logged_in_user,
@ -53,4 +57,3 @@ impl Default for GlobalState {
Self::new()
}
}