Some checks failed
Push Workflows / rustfmt (push) Successful in 11s
Push Workflows / mdbook (push) Successful in 16s
Push Workflows / mdbook-server (push) Successful in 4m20s
Push Workflows / docs (push) Successful in 5m44s
Push Workflows / clippy (push) Successful in 7m48s
Push Workflows / test (push) Successful in 11m14s
Push Workflows / leptos-test (push) Failing after 11m33s
Push Workflows / build (push) Successful in 12m53s
Push Workflows / nix-build (push) Successful in 16m54s
Push Workflows / docker-build (push) Successful in 17m40s
393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
use crate::models::{backend, frontend};
|
|
use crate::util::error::*;
|
|
use crate::util::serverfn_client::Client;
|
|
use cfg_if::cfg_if;
|
|
use leptos::prelude::*;
|
|
use server_fn::codec::{MultipartData, MultipartFormData};
|
|
|
|
cfg_if! {
|
|
if #[cfg(feature = "ssr")] {
|
|
use crate::api::auth::get_user;
|
|
use diesel::prelude::*;
|
|
use crate::util::database::get_db_conn;
|
|
use crate::util::extract_field::extract_field;
|
|
use std::collections::HashMap;
|
|
use log::*;
|
|
use crate::schema::*;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "ssr")]
|
|
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> BackendResult<bool> {
|
|
let mut db_conn = get_db_conn();
|
|
|
|
let exists = playlists::table
|
|
.find(playlist_id)
|
|
.filter(playlists::owner_id.eq(user_id))
|
|
.select(playlists::id)
|
|
.first::<i32>(&mut db_conn)
|
|
.optional()
|
|
.context("Error loading playlist from database")?
|
|
.is_some();
|
|
|
|
Ok(exists)
|
|
}
|
|
|
|
#[server(endpoint = "playlists/get_all", client = Client)]
|
|
pub async fn get_playlists() -> BackendResult<Vec<backend::Playlist>> {
|
|
let user_id = get_user().await.context("Error getting logged-in user")?.id;
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
let playlists = playlists::table
|
|
.filter(playlists::owner_id.eq(user_id))
|
|
.select(playlists::all_columns)
|
|
.load::<backend::Playlist>(&mut db_conn)
|
|
.context("Error loading playlists from database")?;
|
|
|
|
Ok(playlists)
|
|
}
|
|
|
|
#[server(endpoint = "playlists/get", client = Client)]
|
|
pub async fn get_playlist(playlist_id: i32) -> BackendResult<backend::Playlist> {
|
|
let user_id = get_user().await.context("Error getting logged-in user")?.id;
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
let playlist: backend::Playlist = playlists::table
|
|
.find(playlist_id)
|
|
.filter(playlists::owner_id.eq(user_id))
|
|
.select(playlists::all_columns)
|
|
.first(&mut db_conn)
|
|
.context("Error loading playlist from database")?;
|
|
|
|
Ok(playlist)
|
|
}
|
|
|
|
#[server(endpoint = "playlists/get_songs", client = Client)]
|
|
pub async fn get_playlist_songs(playlist_id: i32) -> BackendResult<Vec<frontend::Song>> {
|
|
let user_id = get_user().await.context("Error getting logged-in user")?.id;
|
|
|
|
// Check if the playlist exists and belongs to the user
|
|
let valid_playlist = user_owns_playlist(user_id, playlist_id)
|
|
.await
|
|
.context("Error checking if playlist exists and is owned by user")?;
|
|
|
|
if !valid_playlist {
|
|
return Err(AccessError::NotFoundOrUnauthorized
|
|
.context("Playlist does not exist or does not belong to the user"));
|
|
}
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
let songs: Vec<(
|
|
backend::Song,
|
|
Option<backend::Album>,
|
|
Option<backend::Artist>,
|
|
Option<(i32, i32)>,
|
|
Option<(i32, i32)>,
|
|
)> = crate::schema::playlist_songs::table
|
|
.filter(crate::schema::playlist_songs::playlist_id.eq(playlist_id))
|
|
.inner_join(
|
|
crate::schema::songs::table
|
|
.on(crate::schema::playlist_songs::song_id.eq(crate::schema::songs::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(&mut db_conn)
|
|
.context("Error loading playlist songs from database")?;
|
|
|
|
let mut playlist_songs: HashMap<i32, frontend::Song> = HashMap::new();
|
|
|
|
for (song, album, artist, like, dislike) in songs {
|
|
if let Some(stored_songdata) = playlist_songs.get_mut(&song.id) {
|
|
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 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,
|
|
};
|
|
|
|
playlist_songs.insert(song.id, songdata);
|
|
}
|
|
}
|
|
|
|
Ok(playlist_songs.into_values().collect())
|
|
}
|
|
|
|
#[server(endpoint = "playlists/add_song", client = Client)]
|
|
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> BackendResult<()> {
|
|
use crate::schema::*;
|
|
|
|
let user = get_user().await.context("Error getting logged-in user")?;
|
|
|
|
// Check if the playlist exists and belongs to the user
|
|
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
|
.await
|
|
.context("Error checking if playlist exists and is owned by user")?;
|
|
|
|
if !valid_playlist {
|
|
return Err(AccessError::NotFoundOrUnauthorized
|
|
.context("Playlist does not exist or does not belong to the user"));
|
|
}
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
diesel::insert_into(crate::schema::playlist_songs::table)
|
|
.values((
|
|
playlist_songs::playlist_id.eq(playlist_id),
|
|
playlist_songs::song_id.eq(song_id),
|
|
))
|
|
.execute(&mut db_conn)
|
|
.context("Error adding song to playlist in database")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[server(input = MultipartFormData, endpoint = "playlists/create")]
|
|
pub async fn create_playlist(data: MultipartData) -> BackendResult<()> {
|
|
use crate::models::backend::NewPlaylist;
|
|
use image_convert::{to_webp, ImageResource, WEBPConfig};
|
|
|
|
let user = get_user().await.context("Error getting logged-in user")?;
|
|
|
|
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
|
|
let mut data = data.into_inner().unwrap();
|
|
|
|
let mut playlist_name = None;
|
|
let mut picture_data = None;
|
|
|
|
while let Ok(Some(field)) = data.next_field().await {
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
|
|
match name.as_str() {
|
|
"name" => {
|
|
playlist_name = Some(extract_field(field).await?);
|
|
}
|
|
"picture" => {
|
|
// Read the image
|
|
let bytes = field
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| InputError::FieldReadError(format!("{e}")))
|
|
.context("Error reading bytes of the picture field")?;
|
|
|
|
// Check if the image is empty
|
|
if !bytes.is_empty() {
|
|
let reader = std::io::Cursor::new(bytes);
|
|
let image_source = ImageResource::from_reader(reader)
|
|
.context("Error creating image resource from reader")?;
|
|
|
|
picture_data = Some(image_source);
|
|
}
|
|
}
|
|
_ => {
|
|
warn!("Unknown playlist creation field: {name}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unwrap mandatory fields
|
|
let name = playlist_name.ok_or_else(|| {
|
|
InputError::MissingField("name".to_string()).context("Missing playlist name")
|
|
})?;
|
|
|
|
let new_playlist = NewPlaylist {
|
|
name: name.clone(),
|
|
owner_id: user.id,
|
|
};
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
// Create a transaction to create the playlist
|
|
// If saving the image fails, the playlist will not be created
|
|
db_conn.transaction(|db_conn| {
|
|
let playlist = diesel::insert_into(playlists::table)
|
|
.values(&new_playlist)
|
|
.get_result::<backend::Playlist>(db_conn)
|
|
.context("Error creating playlist in database")?;
|
|
|
|
// If a picture was provided, save it to the database
|
|
if let Some(image_source) = picture_data {
|
|
let image_path = format!("assets/images/playlist/{}.webp", playlist.id);
|
|
|
|
let mut image_target = ImageResource::from_path(&image_path);
|
|
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
|
|
.map_err(|e| InputError::InvalidInput(format!("{e}")))
|
|
.context("Error converting image to webp")?;
|
|
}
|
|
|
|
Ok::<(), BackendError>(())
|
|
})
|
|
}
|
|
|
|
#[server(input = MultipartFormData, endpoint = "playlists/edit_image")]
|
|
pub async fn edit_playlist_image(data: MultipartData) -> BackendResult<()> {
|
|
use image_convert::{to_webp, ImageResource, WEBPConfig};
|
|
|
|
let user = get_user().await.context("Error getting logged-in user")?;
|
|
|
|
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
|
|
let mut data = data.into_inner().unwrap();
|
|
|
|
let mut playlist_id = None;
|
|
let mut picture_data = None;
|
|
|
|
while let Ok(Some(field)) = data.next_field().await {
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
|
|
match name.as_str() {
|
|
"id" => {
|
|
playlist_id = Some(extract_field(field).await?);
|
|
}
|
|
"picture" => {
|
|
// Read the image
|
|
let bytes = field
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| InputError::FieldReadError(format!("{e}")))
|
|
.context("Error reading bytes of the picture field")?;
|
|
|
|
// Check if the image is empty
|
|
if !bytes.is_empty() {
|
|
let reader = std::io::Cursor::new(bytes);
|
|
let image_source = ImageResource::from_reader(reader)
|
|
.context("Error creating image resource from reader")?;
|
|
|
|
picture_data = Some(image_source);
|
|
}
|
|
}
|
|
_ => {
|
|
warn!("Unknown playlist creation field: {name}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unwrap mandatory fields
|
|
let playlist_id = playlist_id
|
|
.ok_or_else(|| InputError::MissingField("id".to_string()).context("Missing playlist ID"))?;
|
|
|
|
let playlist_id: i32 = playlist_id
|
|
.parse()
|
|
.map_err(|e| InputError::InvalidInput(format!("Invalid playlist ID: {e}")))
|
|
.context("Error parsing playlist ID from string")?;
|
|
|
|
// Make sure the playlist exists and belongs to the user
|
|
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
|
.await
|
|
.context("Error checking if playlist exists and is owned by user")?;
|
|
|
|
if !valid_playlist {
|
|
return Err(AccessError::NotFoundOrUnauthorized
|
|
.context("Playlist does not exist or does not belong to the user"));
|
|
}
|
|
|
|
// If a picture was provided, save it to the database
|
|
if let Some(image_source) = picture_data {
|
|
let image_path = format!("assets/images/playlist/{playlist_id}.webp");
|
|
|
|
let mut image_target = ImageResource::from_path(&image_path);
|
|
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
|
|
.map_err(|e| InputError::InvalidInput(format!("{e}")))
|
|
.context("Error converting image to webp")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[server(endpoint = "playlists/delete", client = Client)]
|
|
pub async fn delete_playlist(playlist_id: i32) -> BackendResult<()> {
|
|
use crate::schema::*;
|
|
|
|
let user = get_user().await.context("Error getting logged-in user")?;
|
|
|
|
// Check if the playlist exists and belongs to the user
|
|
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
|
.await
|
|
.context("Error checking if playlist exists and is owned by user")?;
|
|
|
|
if !valid_playlist {
|
|
return Err(AccessError::NotFoundOrUnauthorized
|
|
.context("Playlist does not exist or does not belong to the user"));
|
|
}
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
diesel::delete(playlists::table.find(playlist_id))
|
|
.execute(&mut db_conn)
|
|
.context("Error deleting playlist from database")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[server(endpoint = "playlists/rename", client = Client)]
|
|
pub async fn rename_playlist(id: i32, new_name: String) -> BackendResult<()> {
|
|
use crate::schema::*;
|
|
|
|
let user = get_user().await.context("Error getting logged-in user")?;
|
|
|
|
// Check if the playlist exists and belongs to the user
|
|
let valid_playlist = user_owns_playlist(user.id, id)
|
|
.await
|
|
.context("Error checking if playlist exists and is owned by user")?;
|
|
|
|
if !valid_playlist {
|
|
return Err(AccessError::NotFoundOrUnauthorized.into());
|
|
}
|
|
|
|
let mut db_conn = get_db_conn();
|
|
|
|
diesel::update(playlists::table.find(id))
|
|
.set(playlists::name.eq(new_name))
|
|
.execute(&mut db_conn)
|
|
.context("Error renaming playlist in database")?;
|
|
|
|
Ok(())
|
|
}
|