diff --git a/.env.example b/.env.example
index ef8c8dd..893f587 100644
--- a/.env.example
+++ b/.env.example
@@ -15,3 +15,6 @@ DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes
# POSTGRES_HOST=localhost
# POSTGRES_PORT=5432
# POSTGRES_DB=libretunes
+
+LIBRETUNES_AUDIO_PATH=assets/audio
+LIBRETUNES_IMAGE_PATH=assets/images
diff --git a/.gitignore b/.gitignore
index ab27e5e..f472188 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ playwright/.cache/
*.jpeg
*.png
*.gif
+*.webp
# Environment variables
.env
diff --git a/Cargo.lock b/Cargo.lock
index 44b8a0a..a726130 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -378,13 +378,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
-version = "0.4.37"
+version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
+ "js-sys",
"num-traits",
+ "serde",
+ "wasm-bindgen",
"windows-targets 0.52.4",
]
@@ -690,11 +693,11 @@ checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559"
dependencies = [
"bitflags 2.5.0",
"byteorder",
+ "chrono",
"diesel_derives",
"itoa",
"pq-sys",
"r2d2",
- "time",
]
[[package]]
@@ -752,10 +755,10 @@ dependencies = [
]
[[package]]
-name = "dotenv"
-version = "0.15.0"
+name = "dotenvy"
+version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "drain_filter_polyfill"
@@ -1563,9 +1566,9 @@ dependencies = [
[[package]]
name = "leptos-use"
-version = "0.13.5"
+version = "0.13.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb"
+checksum = "32d4708472867704085a2813c47cada122a6e8c3b90ccff764862c0b351bfb96"
dependencies = [
"cfg-if",
"codee",
@@ -1834,10 +1837,11 @@ dependencies = [
"axum",
"axum-login",
"cfg-if",
+ "chrono",
"console_error_panic_hook",
"diesel",
"diesel_migrations",
- "dotenv",
+ "dotenvy",
"flexi_logger",
"http 1.1.0",
"icondata",
@@ -1857,7 +1861,6 @@ dependencies = [
"server_fn",
"symphonia",
"thiserror",
- "time",
"tokio",
"tower 0.5.1",
"tower-http",
@@ -3441,9 +3444,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
-version = "0.2.93"
+version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
+checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
dependencies = [
"cfg-if",
"once_cell",
@@ -3452,9 +3455,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
-version = "0.2.93"
+version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
+checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
dependencies = [
"bumpalo",
"log",
@@ -3479,9 +3482,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.93"
+version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
+checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3489,9 +3492,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.93"
+version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
+checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
dependencies = [
"proc-macro2",
"quote",
@@ -3502,9 +3505,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.93"
+version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
+checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
[[package]]
name = "wasm-streams"
diff --git a/Cargo.toml b/Cargo.toml
index 2bdcda4..1f30a39 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,15 +15,13 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] }
leptos_meta = { version = "0.6", features = ["nightly"] }
leptos_axum = { version = "0.6", optional = true }
leptos_router = { version = "0.6", features = ["nightly"] }
-wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true }
+wasm-bindgen = { version = "=0.2.95", default-features = false, optional = true }
leptos_icons = { version = "0.3.0" }
icondata = { version = "0.3.0" }
-dotenv = { version = "0.15.0", optional = true }
-diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], default-features = false, optional = true }
+diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true }
lazy_static = { version = "1.4.0", optional = true }
serde = { version = "1.0.195", features = ["derive"], default-features = false }
openssl = { version = "0.10.63", optional = true }
-time = { version = "0.3.34", features = ["serde"], default-features = false }
diesel_migrations = { version = "2.1.0", optional = true }
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
@@ -42,6 +40,8 @@ flexi_logger = { version = "0.28.0", optional = true, default-features = false }
web-sys = "0.3.69"
leptos-use = "0.13.5"
image-convert = { version = "0.18.0", optional = true, default-features = false }
+chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
+dotenvy = { version = "0.15.7", optional = true }
[features]
hydrate = [
@@ -50,13 +50,14 @@ hydrate = [
"leptos_router/hydrate",
"console_error_panic_hook",
"wasm-bindgen",
+ "chrono/wasmbind",
]
ssr = [
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
- "dotenv",
+ "dotenvy",
"diesel",
"lazy_static",
"openssl",
diff --git a/docker-compose.yml b/docker-compose.yml
index c2d0865..c3d466d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,8 +13,11 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
+ LIBRETUNES_AUDIO_PATH: /assets/audio
+ LIBRETUNES_IMAGE_PATH: /assets/images
volumes:
- - libretunes-audio:/site/audio
+ - libretunes-audio:/assets/audio
+ - libretunes-images:/assets/images
depends_on:
- redis
- postgres
@@ -50,5 +53,6 @@ services:
volumes:
libretunes-audio:
+ libretunes-images:
libretunes-redis:
libretunes-postgres:
diff --git a/migrations/2024-10-22-212759_create_playlist_tables/down.sql b/migrations/2024-10-22-212759_create_playlist_tables/down.sql
new file mode 100644
index 0000000..defde9d
--- /dev/null
+++ b/migrations/2024-10-22-212759_create_playlist_tables/down.sql
@@ -0,0 +1,5 @@
+DROP INDEX playlists_owner_idx;
+DROP TABLE playlists;
+
+DROP INDEX playlist_songs_playlist_idx;
+DROP TABLE playlist_songs;
diff --git a/migrations/2024-10-22-212759_create_playlist_tables/up.sql b/migrations/2024-10-22-212759_create_playlist_tables/up.sql
new file mode 100644
index 0000000..cf35cb8
--- /dev/null
+++ b/migrations/2024-10-22-212759_create_playlist_tables/up.sql
@@ -0,0 +1,17 @@
+CREATE TABLE playlists (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
+ name TEXT NOT NULL
+);
+
+CREATE INDEX playlists_owner_idx ON playlists(owner_id);
+
+CREATE TABLE playlist_songs (
+ playlist_id INTEGER REFERENCES playlists(id) ON DELETE CASCADE NOT NULL,
+ song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL,
+ PRIMARY KEY (playlist_id, song_id)
+);
+
+CREATE INDEX playlist_songs_playlist_idx ON playlist_songs(playlist_id);
diff --git a/src/albumdata.rs b/src/albumdata.rs
index e42baea..2b86b25 100644
--- a/src/albumdata.rs
+++ b/src/albumdata.rs
@@ -1,7 +1,7 @@
use crate::models::Artist;
use crate::components::dashboard_tile::DashboardTile;
-use time::Date;
+use chrono::NaiveDate;
/// Holds information about an album
///
@@ -14,7 +14,7 @@ pub struct AlbumData {
/// Album artists
pub artists: Vec,
/// Album release date
- pub release_date: Option,
+ pub release_date: Option,
/// Path to album image, relative to the root of the web server.
/// For example, `"/assets/images/Album.jpg"`
pub image_path: String,
diff --git a/src/api/history.rs b/src/api/history.rs
index 5f6cabb..697b255 100644
--- a/src/api/history.rs
+++ b/src/api/history.rs
@@ -1,4 +1,4 @@
-use std::time::SystemTime;
+use chrono::NaiveDateTime;
use leptos::*;
use crate::models::HistoryEntry;
use crate::models::Song;
@@ -25,7 +25,7 @@ pub async fn get_history(limit: Option) -> Result, Server
/// Get the listen dates and songs of the current user.
#[server(endpoint = "history/get_songs")]
-pub async fn get_history_songs(limit: Option) -> Result, ServerFnError> {
+pub async fn get_history_songs(limit: Option) -> Result, ServerFnError> {
let user = get_user().await?;
let db_con = &mut get_db_conn();
let songs = user.get_history_songs(limit, db_con)
diff --git a/src/api/profile.rs b/src/api/profile.rs
index 790af13..f994b49 100644
--- a/src/api/profile.rs
+++ b/src/api/profile.rs
@@ -3,10 +3,23 @@ use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
+use crate::songdata::SongData;
+use crate::artistdata::ArtistData;
+
+use chrono::NaiveDateTime;
+
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::auth::get_user;
use server_fn::error::NoCustomError;
+
+ use crate::database::get_db_conn;
+ use diesel::prelude::*;
+ use diesel::dsl::count;
+ use crate::models::*;
+ use crate::schema::*;
+
+ use std::collections::HashMap;
}
}
@@ -47,3 +60,241 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
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")]
+pub async fn recent_songs(for_user_id: i32, limit: Option) -> Result, ServerFnError> {
+ let mut db_con = get_db_conn();
+
+ // Get the ids of the most recent songs listened to
+ let history_items: Vec =
+ if let Some(limit) = limit {
+ song_history::table
+ .filter(song_history::user_id.eq(for_user_id))
+ .order(song_history::date.desc())
+ .limit(limit)
+ .select(song_history::id)
+ .load(&mut db_con)?
+ } else {
+ song_history::table
+ .filter(song_history::user_id.eq(for_user_id))
+ .order(song_history::date.desc())
+ .select(song_history::id)
+ .load(&mut db_con)?
+ };
+
+ // Take the history ids and get the song data for them
+ let history: Vec<(HistoryEntry, Song, Option, Option, Option<(i32, i32)>, Option<(i32, i32)>)>
+ = song_history::table
+ .filter(song_history::id.eq_any(history_items))
+ .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(for_user_id))))
+ .left_join(song_dislikes::table.on(
+ songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_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 = HashMap::with_capacity(history.len());
+
+ 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().map(|album| album.image_path.clone()).flatten()
+ .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
+
+ let songdata = SongData {
+ id: song_id,
+ title: song.title,
+ artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
+ album: album,
+ track: song.track,
+ duration: song.duration,
+ release_date: song.release_date,
+ song_path: song.storage_path,
+ image_path: image_path,
+ like_dislike: like_dislike,
+ };
+
+ history_songs.insert(song_id, (history.date, songdata));
+ }
+ }
+
+ // Sort the songs by date
+ let mut history_songs: Vec<(NaiveDateTime, SongData)> = 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")]
+pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option)
+ -> Result, ServerFnError>
+{
+ 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 = history_counts.into_iter().collect();
+ let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::>();
+
+ // Get the song data for the songs listened to in the date range
+ let history_songs: Vec<(Song, Option, Option, 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(for_user_id))))
+ .left_join(song_dislikes::table.on(
+ songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_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 = HashMap::with_capacity(history_counts.len());
+
+ for (song, album, artist, like, dislike) in history_songs {
+ let song_id = song.id
+ .ok_or(ServerFnError::ServerError::("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,
+ };
+
+ let image_path = song.image_path.unwrap_or(
+ album.as_ref().map(|album| album.image_path.clone()).flatten()
+ .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
+
+ let songdata = SongData {
+ id: song_id,
+ title: song.title,
+ artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
+ album: album,
+ track: song.track,
+ duration: song.duration,
+ release_date: song.release_date,
+ song_path: song.storage_path,
+ image_path: image_path,
+ like_dislike: like_dislike,
+ };
+
+ let plays = history_counts.get(&song_id)
+ .ok_or(ServerFnError::ServerError::("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, SongData)> = 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")]
+pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option)
+ -> Result, 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, ArtistData)> = artist_counts.into_iter().map(|(plays, artist)| {
+ (plays, ArtistData {
+ id: artist.id.unwrap(),
+ name: artist.name,
+ image_path: format!("/assets/images/artists/{}.webp", artist.id.unwrap()),
+ })
+ }).collect();
+
+ Ok(artist_data)
+}
diff --git a/src/app.rs b/src/app.rs
index e3e9226..4784702 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,23 +1,23 @@
use crate::playbar::PlayBar;
use crate::playbar::CustomTitle;
-use crate::playstatus::PlayStatus;
use crate::queue::Queue;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use crate::pages::login::*;
use crate::pages::signup::*;
+use crate::pages::profile::*;
use crate::pages::albumpage::*;
use crate::error_template::{AppError, ErrorTemplate};
-
+use crate::util::state::GlobalState;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
- let play_status = PlayStatus::default();
- let play_status = create_rw_signal(play_status);
+ provide_context(GlobalState::new());
+
let upload_open = create_rw_signal(false);
view! {
@@ -26,7 +26,7 @@ pub fn App() -> impl IntoView {
// sets the document title
-
+
// content for this welcome page
impl IntoView {
}>
- }>
+ }>
+
+
@@ -56,12 +58,12 @@ pub fn App() -> impl IntoView {
use crate::components::sidebar::*;
use crate::components::dashboard::*;
use crate::components::search::*;
-use crate::components::personal::*;
+use crate::components::personal::Personal;
use crate::components::upload::*;
/// Renders the home page of your application.
#[component]
-fn HomePage(play_status: RwSignal, upload_open: RwSignal) -> impl IntoView {
+fn HomePage(upload_open: RwSignal) -> impl IntoView {
view! {
@@ -69,8 +71,8 @@ fn HomePage(play_status: RwSignal, upload_open: RwSignal) -> i
// This will render the child route components
-
-
+
+
}
}
diff --git a/src/artistdata.rs b/src/artistdata.rs
index e799679..401979d 100644
--- a/src/artistdata.rs
+++ b/src/artistdata.rs
@@ -1,8 +1,10 @@
use crate::components::dashboard_tile::DashboardTile;
+use serde::{Serialize, Deserialize};
/// Holds information about an artist
///
/// Intended to be used in the front-end
+#[derive(Clone, Serialize, Deserialize)]
pub struct ArtistData {
/// Artist id
pub id: i32,
diff --git a/src/auth.rs b/src/auth.rs
index 37f861f..558bfe2 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -57,7 +57,7 @@ pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
/// Takes in a username or email and a password in plaintext
/// Returns a Result with a boolean indicating if the login was successful
#[server(endpoint = "login")]
-pub async fn login(credentials: UserCredentials) -> Result {
+pub async fn login(credentials: UserCredentials) -> Result
})
+ .collect_view()
+ }
+ }
+ >
+ {move ||
+ top_songs.get().map(|top_songs| {
+ top_songs.map(|top_songs| {
+ view! {
+
+ }
+ })
+ })
+ }
+
+
+ }
+}
+
+/// Show a list of recently played songs for a user
+#[component]
+fn RecentSongs(#[prop(into)] user_id: MaybeSignal) -> impl IntoView {
+ let recent_songs = create_resource(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::>()
+ })
+ });
+
+ view! {
+
"Recently Played"
+ }
+ >
+ {e.to_string()}})
+ .collect_view()
+ }
+ }
+ >
+ {move ||
+ recent_songs.get().map(|recent_songs| {
+ recent_songs.map(|recent_songs| {
+ view! {
+
+ }
+ })
+ })
+ }
+
+
+ }
+}
+
+/// Show a list of top artists for a user
+#[component]
+fn TopArtists(#[prop(into)] user_id: MaybeSignal) -> impl IntoView {
+ let top_artists = create_resource(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_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::>()
+ })
+ });
+
+ view! {
+ {format!("Top Artists {}", HISTORY_MESSAGE)}
+
+ }
+ >
+ {format!("Top Artists {}", HISTORY_MESSAGE)}
+ {move || errors.get()
+ .into_iter()
+ .map(|(_, e)| view! {
{e.to_string()}
})
+ .collect_view()
+ }
+ }
+ >
+ {move ||
+ top_artists.get().map(|top_artists| {
+ top_artists.map(|top_artists| {
+ let tiles = top_artists.into_iter().map(|artist| {
+ Box::new(artist) as Box
+ }).collect::>();
+
+ DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles)
+ })
+ })
+ }
+
+
+ }
+}
diff --git a/src/pages/signup.rs b/src/pages/signup.rs
index f02dfab..69fe77d 100644
--- a/src/pages/signup.rs
+++ b/src/pages/signup.rs
@@ -1,5 +1,6 @@
use crate::auth::signup;
use crate::models::User;
+use crate::util::state::GlobalState;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::*;
@@ -19,7 +20,7 @@ pub fn Signup() -> impl IntoView {
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
- let new_user = User {
+ let mut new_user = User {
id: None,
username: username.get(),
email: email.get(),
@@ -29,11 +30,20 @@ pub fn Signup() -> impl IntoView {
};
log!("new user: {:?}", new_user);
+ let user = GlobalState::logged_in_user();
+
spawn_local(async move {
- if let Err(err) = signup(new_user).await {
+ if let Err(err) = signup(new_user.clone()).await {
// Handle the error here, e.g., log it or display to the user
log!("Error signing up: {:?}", err);
+
+ // Since we're not sure what the state is, manually refetch the user
+ user.refetch();
} else {
+ // Manually set the user to the new user, avoiding a refetch
+ new_user.password = None;
+ user.set(Some(new_user));
+
// Redirect to the login page
log!("Signed up successfully!");
leptos_router::use_navigate()("/", Default::default());
diff --git a/src/playbar.rs b/src/playbar.rs
index c14e9f7..f8e656a 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -1,7 +1,7 @@
use crate::models::Artist;
-use crate::playstatus::PlayStatus;
use crate::songdata::SongData;
use crate::api::songs;
+use crate::util::state::GlobalState;
use leptos::ev::MouseEvent;
use leptos::html::{Audio, Div};
use leptos::leptos_dom::*;
@@ -40,8 +40,8 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
/// * `None` if the audio element is not available
/// * `Some((current_time, duration))` if the audio element is available
///
-pub fn get_song_time_duration(status: impl SignalWithUntracked) -> Option<(f64, f64)> {
- status.with_untracked(|status| {
+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 {
@@ -61,13 +61,13 @@ pub fn get_song_time_duration(status: impl SignalWithUntracked, time: f64) {
+pub fn skip_to(time: f64) {
if time.is_infinite() || time.is_nan() {
error!("Unable to skip to non-finite time: {}", time);
return
}
- status.update(|status| {
+ GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() {
audio.set_current_time(time);
log!("Player skipped to time: {}", time);
@@ -85,8 +85,8 @@ pub fn skip_to(status: impl SignalUpdate, time: f64) {
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `play` - `true` to play the song, `false` to pause it
///
-pub fn set_playing(status: impl SignalUpdate, play: bool) {
- status.update(|status| {
+pub fn set_playing(play: bool) {
+ GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() {
if play {
if let Err(e) = audio.play() {
@@ -109,8 +109,8 @@ pub fn set_playing(status: impl SignalUpdate, play: bool) {
});
}
-fn toggle_queue(status: impl SignalUpdate) {
- status.update(|status| {
+fn toggle_queue() {
+ GlobalState::play_status().update(|status| {
status.queue_open = !status.queue_open;
});
@@ -126,8 +126,8 @@ fn toggle_queue(status: impl SignalUpdate) {
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `src` - The source to set the audio player to
///
-fn set_play_src(status: impl SignalUpdate, src: String) {
- status.update(|status| {
+fn set_play_src(src: String) {
+ GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() {
audio.set_src(&src);
log!("Player set src to: {}", src);
@@ -139,11 +139,13 @@ fn set_play_src(status: impl SignalUpdate, src: String) {
/// The play, pause, and skip buttons
#[component]
-fn PlayControls(status: RwSignal) -> impl IntoView {
+fn PlayControls() -> impl IntoView {
+ let status = GlobalState::play_status();
+
// On click handlers for the skip and play/pause buttons
let skip_back = move |_| {
- if let Some(duration) = get_song_time_duration(status) {
+ if let Some(duration) = get_song_time_duration() {
// Skip to previous song if the current song is near the start
// Also skip to the previous song if we're at the end of the current song
// This is because after running out of songs in the queue, the current song will be at the end
@@ -160,8 +162,8 @@ fn PlayControls(status: RwSignal) -> impl IntoView {
// Push the popped song to the front of the queue, and play it
let next_src = last_played_song.song_path.clone();
status.update(|status| status.queue.push_front(last_played_song));
- set_play_src(status, next_src);
- set_playing(status, true);
+ set_play_src(next_src);
+ set_playing(true);
} else {
warn!("Unable to skip back: No previous song");
}
@@ -170,14 +172,14 @@ fn PlayControls(status: RwSignal) -> impl IntoView {
// Default to skipping to start of current song, and playing
log!("Skipping to start of current song");
- skip_to(status, 0.0);
- set_playing(status, true);
+ skip_to(0.0);
+ set_playing(true);
};
let skip_forward = move |_| {
- if let Some(duration) = get_song_time_duration(status) {
- skip_to(status, duration.1);
- set_playing(status, true);
+ if let Some(duration) = get_song_time_duration() {
+ skip_to(duration.1);
+ set_playing(true);
} else {
error!("Unable to skip forward: Unable to get current duration");
}
@@ -185,7 +187,7 @@ fn PlayControls(status: RwSignal) -> impl IntoView {
let toggle_play = move |_| {
let playing = status.with_untracked(|status| { status.playing });
- set_playing(status, !playing);
+ set_playing(!playing);
};
// We use this to prevent the buttons from being focused when clicked
@@ -248,7 +250,9 @@ fn PlayDuration(elapsed_secs: MaybeSignal, total_secs: MaybeSignal) ->
/// The name, artist, and album of the current song
#[component]
-fn MediaInfo(status: RwSignal) -> impl IntoView {
+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())
@@ -287,7 +291,9 @@ fn MediaInfo(status: RwSignal) -> impl IntoView {
/// The like and dislike buttons
#[component]
-fn LikeDislike(status: RwSignal) -> impl IntoView {
+fn LikeDislike() -> impl IntoView {
+ let status = GlobalState::play_status();
+
let like_icon = Signal::derive(move || {
status.with(|status| {
match status.queue.front() {
@@ -400,7 +406,7 @@ fn LikeDislike(status: RwSignal) -> impl IntoView {
/// The play progress bar, and click handler for skipping to a certain time in the song
#[component]
-fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> impl IntoView {
+fn ProgressBar(percentage: MaybeSignal) -> impl IntoView {
// Keep a reference to the progress bar div so we can get its width and calculate the time to skip to
let progress_bar_ref = create_node_ref::
();
@@ -412,10 +418,10 @@ fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> im
let width = progress_bar.offset_width() as f64;
let percentage = x_click_pos / width * 100.0;
- if let Some(duration) = get_song_time_duration(status) {
+ if let Some(duration) = get_song_time_duration() {
let time = duration.1 * percentage / 100.0;
- skip_to(status, time);
- set_playing(status, true);
+ skip_to(time);
+ set_playing(true);
} else {
error!("Unable to skip to time: Unable to get current duration");
}
@@ -438,11 +444,11 @@ fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> im
}
#[component]
-fn QueueToggle(status: RwSignal) -> impl IntoView {
-
+fn QueueToggle() -> impl IntoView {
let update_queue = move |_| {
- toggle_queue(status);
- log!("queue button pressed, queue status: {:?}", status.with_untracked(|status| status.queue_open));
+ toggle_queue();
+ log!("queue button pressed, queue status: {:?}",
+ GlobalState::play_status().with_untracked(|status| status.queue_open));
};
// We use this to prevent the buttons from being focused when clicked
@@ -463,9 +469,9 @@ fn QueueToggle(status: RwSignal) -> impl IntoView {
/// Renders the title of the page based on the currently playing song
#[component]
-pub fn CustomTitle(play_status: RwSignal) -> impl IntoView {
+pub fn CustomTitle() -> impl IntoView {
let title = create_memo(move |_| {
- play_status.with(|play_status| {
+ 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")
})
@@ -478,18 +484,20 @@ pub fn CustomTitle(play_status: RwSignal) -> impl IntoView {
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
#[component]
-pub fn PlayBar(status: RwSignal) -> impl IntoView {
+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(ev::keydown, move |e: 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(status) {
+ 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(status, time);
- set_playing(status, true);
+ skip_to(time);
+ set_playing(true);
} else {
error!("Unable to skip forward: Unable to get current duration");
}
@@ -498,11 +506,11 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView {
e.prevent_default();
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
- if let Some(duration) = get_song_time_duration(status) {
+ 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(status, time);
- set_playing(status, true);
+ skip_to(time);
+ set_playing(true);
} else {
error!("Unable to skip backward: Unable to get current duration");
}
@@ -516,7 +524,7 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView {
log!("Space bar pressed, toggling play/pause");
let playing = status.with_untracked(|status| status.playing);
- set_playing(status, !playing);
+ set_playing(!playing);
}
});
@@ -659,14 +667,14 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView {
-
+
-
-
+
+
-
+
-
+
}
}
diff --git a/src/queue.rs b/src/queue.rs
index 8a819b9..2204809 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -1,6 +1,6 @@
use crate::models::Artist;
-use crate::playstatus::PlayStatus;
use crate::song::Song;
+use crate::util::state::GlobalState;
use leptos::ev::MouseEvent;
use leptos::leptos_dom::*;
use leptos::*;
@@ -9,22 +9,23 @@ use leptos::ev::DragEvent;
const RM_BTN_SIZE: &str = "2.5rem";
-fn remove_song_fn(index: usize, status: RwSignal) {
+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");
- status.update(|status| {
+ GlobalState::play_status().update(|status| {
status.queue.remove(index);
});
}
}
#[component]
-pub fn Queue(status: RwSignal) -> impl IntoView {
+pub fn Queue() -> impl IntoView {
+ let status = GlobalState::play_status();
let remove_song = move |index: usize| {
- remove_song_fn(index, status);
+ remove_song_fn(index);
log!("Removed song {}", index + 1);
};
diff --git a/src/schema.rs b/src/schema.rs
index 29401e7..31aebd6 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -39,6 +39,23 @@ diesel::table! {
}
}
+diesel::table! {
+ playlist_songs (playlist_id, song_id) {
+ playlist_id -> Int4,
+ song_id -> Int4,
+ }
+}
+
+diesel::table! {
+ playlists (id) {
+ id -> Int4,
+ created_at -> Timestamp,
+ updated_at -> Timestamp,
+ owner_id -> Int4,
+ name -> Text,
+ }
+}
+
diesel::table! {
song_artists (song_id, artist_id) {
song_id -> Int4,
@@ -95,6 +112,9 @@ diesel::table! {
diesel::joinable!(album_artists -> albums (album_id));
diesel::joinable!(album_artists -> artists (artist_id));
+diesel::joinable!(playlist_songs -> playlists (playlist_id));
+diesel::joinable!(playlist_songs -> songs (song_id));
+diesel::joinable!(playlists -> users (owner_id));
diesel::joinable!(song_artists -> artists (artist_id));
diesel::joinable!(song_artists -> songs (song_id));
diesel::joinable!(song_dislikes -> songs (song_id));
@@ -111,6 +131,8 @@ diesel::allow_tables_to_appear_in_same_query!(
artists,
friend_requests,
friendships,
+ playlist_songs,
+ playlists,
song_artists,
song_dislikes,
song_history,
diff --git a/src/songdata.rs b/src/songdata.rs
index 1da9e69..a70707f 100644
--- a/src/songdata.rs
+++ b/src/songdata.rs
@@ -2,12 +2,12 @@ use crate::models::{Album, Artist, Song};
use crate::components::dashboard_tile::DashboardTile;
use serde::{Serialize, Deserialize};
-use time::Date;
+use chrono::NaiveDate;
/// 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(Clone, Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, Clone)]
pub struct SongData {
/// Song id
pub id: i32,
@@ -22,7 +22,7 @@ pub struct SongData {
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
- pub release_date: Option,
+ pub release_date: Option,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
@@ -50,7 +50,6 @@ impl TryInto for SongData {
track: self.track,
duration: self.duration,
release_date: self.release_date,
- // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35
storage_path: self.song_path,
// Note that if the source of the image_path was the album, the image_path
diff --git a/src/upload.rs b/src/upload.rs
index ff7d30d..083b0d2 100644
--- a/src/upload.rs
+++ b/src/upload.rs
@@ -10,7 +10,7 @@ cfg_if! {
use diesel::prelude::*;
use log::*;
use server_fn::error::NoCustomError;
- use time::Date;
+ use chrono::NaiveDate;
}
}
@@ -124,15 +124,14 @@ async fn validate_track_number(track_number: Field<'static>) -> Result
) -> Result
, ServerFnError> {
+async fn validate_release_date(release_date: Field<'static>) -> Result
, ServerFnError> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
return Ok(None);
}
- let date_format = time::macros::format_description!("[year]-[month]-[day]");
- let release_date = Date::parse(&release_date.trim(), date_format);
+ let release_date = NaiveDate::parse_from_str(&release_date.trim(), "%Y-%m-%d");
match release_date {
Ok(release_date) => Ok(Some(release_date)),
@@ -181,8 +180,7 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
ServerError("Title field required and must precede file field".to_string()))?;
let clean_title = title.replace(" ", "_").replace("/", "_");
- let date_format = time::macros::format_description!("[year]-[month]-[day]_[hour]:[minute]:[second]");
- let date_str = time::OffsetDateTime::now_utc().format(date_format).unwrap_or_default();
+ let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
let upload_path = format!("assets/audio/upload-{}_{}.mp3", date_str, clean_title);
file_name = Some(format!("upload-{}_{}.mp3", date_str, clean_title));
diff --git a/src/users.rs b/src/users.rs
index 2d61c69..ff5606f 100644
--- a/src/users.rs
+++ b/src/users.rs
@@ -128,3 +128,15 @@ pub async fn get_user(username_or_email: String) -> Result