Files
LibreTunes/src/api/profile.rs
Ethan Girouard 54d629d504
Some checks failed
Push Workflows / rustfmt (push) Successful in 8s
Push Workflows / nix-build (push) Failing after 45s
Push Workflows / docs (push) Successful in 3m20s
Push Workflows / clippy (push) Successful in 4m24s
Push Workflows / test (push) Successful in 5m58s
Push Workflows / leptos-test (push) Successful in 6m59s
Push Workflows / build (push) Successful in 7m53s
Push Workflows / docker-build (push) Failing after 11m47s
Use db_type for User
2025-05-05 01:25:20 +00:00

376 lines
14 KiB
Rust

use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use diesel::dsl::count;
use crate::models::backend::{Album, Artist, Song, HistoryEntry};
use crate::schema::*;
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();
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(),
));
}
// Get user id from session
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
// 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 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/{}.webp", user.id);
let mut image_target = ImageResource::from_path(&profile_picture_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error converting image to webp: {e}"))
})?;
Ok(())
}
/// Get a user's recent songs listened to
/// Optionally takes a limit parameter to limit the number of songs returned.
/// If not provided, all songs ever listend to are returned.
/// 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", client = Client)]
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;
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);
// 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()
};
// 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();
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,
};
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,
};
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)
}
/// Get a user's top songs by play count from a date range
/// Optionally takes a limit parameter to limit the number of songs returned.
/// 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", client = Client)]
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;
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)?
};
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)?;
// 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 {
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 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,
};
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));
}
}
// 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
/// Optionally takes a limit parameter to limit the number of artists returned.
/// 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", client = Client)]
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_data: Vec<(i64, frontend::Artist)> = artist_counts
.into_iter()
.map(|(plays, artist)| {
(
plays,
frontend::Artist {
id: artist.id,
name: artist.name,
image_path: format!("/assets/images/artist/{}.webp", artist.id),
},
)
})
.collect();
Ok(artist_data)
}