Files
LibreTunes/src/api/playlists.rs
Ethan Girouard 368f673fd7
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
Rewrite error handling and display
2025-06-26 00:01:49 +00:00

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(())
}