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 23984ed..c6e155b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
-version = 3
+version = 4
[[package]]
name = "addr2line"
@@ -151,7 +151,7 @@ dependencies = [
"axum-core",
"bytes",
"futures-util",
- "http",
+ "http 1.1.0",
"http-body",
"http-body-util",
"hyper",
@@ -167,7 +167,7 @@ dependencies = [
"serde",
"sync_wrapper 1.0.0",
"tokio",
- "tower",
+ "tower 0.4.13",
"tower-layer",
"tower-service",
]
@@ -181,7 +181,7 @@ dependencies = [
"async-trait",
"bytes",
"futures-util",
- "http",
+ "http 1.1.0",
"http-body",
"http-body-util",
"mime",
@@ -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]]
@@ -1017,13 +1020,14 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "gloo-net"
version = "0.5.0"
-source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils",
- "http",
+ "http 0.2.12",
"js-sys",
"pin-project",
"serde",
@@ -1049,7 +1053,8 @@ dependencies = [
[[package]]
name = "gloo-utils"
version = "0.2.0"
-source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
@@ -1117,6 +1122,17 @@ dependencies = [
"utf8-width",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.1.0"
@@ -1135,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
dependencies = [
"bytes",
- "http",
+ "http 1.1.0",
]
[[package]]
@@ -1146,7 +1162,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.1.0",
"http-body",
"pin-project-lite",
]
@@ -1178,7 +1194,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
+ "http 1.1.0",
"http-body",
"httparse",
"httpdate",
@@ -1196,7 +1212,7 @@ checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.1.0",
"http-body",
"hyper",
"pin-project-lite",
@@ -1550,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",
@@ -1821,12 +1837,13 @@ dependencies = [
"axum",
"axum-login",
"cfg-if",
+ "chrono",
"console_error_panic_hook",
"diesel",
"diesel_migrations",
"dotenv",
"flexi_logger",
- "http",
+ "http 1.1.0",
"icondata",
"image-convert",
"lazy_static",
@@ -1844,9 +1861,8 @@ dependencies = [
"server_fn",
"symphonia",
"thiserror",
- "time",
"tokio",
- "tower",
+ "tower 0.5.1",
"tower-http",
"tower-sessions-redis-store",
"wasm-bindgen",
@@ -2023,7 +2039,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
- "http",
+ "http 1.1.0",
"httparse",
"log",
"memchr",
@@ -2670,7 +2686,7 @@ dependencies = [
"dashmap",
"futures",
"gloo-net",
- "http",
+ "http 1.1.0",
"http-body-util",
"hyper",
"inventory",
@@ -2683,7 +2699,7 @@ dependencies = [
"serde_qs",
"server_fn_macro_default",
"thiserror",
- "tower",
+ "tower 0.4.13",
"tower-layer",
"url",
"wasm-bindgen",
@@ -3109,6 +3125,20 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tower"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper 0.1.2",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "tower-cookies"
version = "0.10.0"
@@ -3119,7 +3149,7 @@ dependencies = [
"axum-core",
"cookie",
"futures-util",
- "http",
+ "http 1.1.0",
"parking_lot",
"pin-project-lite",
"tower-layer",
@@ -3128,14 +3158,14 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.5.2"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
+checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97"
dependencies = [
"bitflags 2.5.0",
"bytes",
"futures-util",
- "http",
+ "http 1.1.0",
"http-body",
"http-body-util",
"http-range-header",
@@ -3153,15 +3183,15 @@ dependencies = [
[[package]]
name = "tower-layer"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
@@ -3170,7 +3200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102"
dependencies = [
"async-trait",
- "http",
+ "http 1.1.0",
"time",
"tokio",
"tower-cookies",
@@ -3191,7 +3221,7 @@ dependencies = [
"axum-core",
"base64",
"futures",
- "http",
+ "http 1.1.0",
"parking_lot",
"rand",
"serde",
@@ -3414,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",
@@ -3425,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",
@@ -3452,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",
@@ -3462,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",
@@ -3475,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 f76ef2f..b4be217 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,21 +15,20 @@ 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"] }
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true }
-tower = { version = "0.4.13", optional = true }
-tower-http = { version = "0.5", optional = true, features = ["fs"] }
+tower = { version = "0.5.1", optional = true, features = ["util"] }
+tower-http = { version = "0.6.1", optional = true, features = ["fs"] }
thiserror = "1.0.57"
tower-sessions-redis-store = { version = "0.11", optional = true }
async-trait = { version = "0.1.79", optional = true }
@@ -42,9 +41,7 @@ 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 }
-
-[patch.crates-io]
-gloo-net = { git = "https://github.com/rustwasm/gloo.git", rev = "a823fab7ecc4068e9a28bd669da5eaf3f0a56380" }
+chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
[features]
hydrate = [
@@ -53,6 +50,7 @@ hydrate = [
"leptos_router/hydrate",
"console_error_panic_hook",
"wasm-bindgen",
+ "chrono/wasmbind",
]
ssr = [
"dep:leptos_axum",
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
new file mode 100644
index 0000000..2b86b25
--- /dev/null
+++ b/src/albumdata.rs
@@ -0,0 +1,39 @@
+use crate::models::Artist;
+use crate::components::dashboard_tile::DashboardTile;
+
+use chrono::NaiveDate;
+
+/// Holds information about an album
+///
+/// Intended to be used in the front-end
+pub struct AlbumData {
+ /// Album id
+ pub id: i32,
+ /// Album title
+ pub title: String,
+ /// Album artists
+ pub artists: Vec,
+ /// Album release date
+ 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,
+}
+
+impl DashboardTile for AlbumData {
+ fn image_path(&self) -> String {
+ self.image_path.clone()
+ }
+
+ fn title(&self) -> String {
+ self.title.clone()
+ }
+
+ fn link(&self) -> String {
+ format!("/album/{}", self.id)
+ }
+
+ fn description(&self) -> Option {
+ Some(format!("Album • {}", Artist::display_list(&self.artists)))
+ }
+}
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 67a6c7a..2058312 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,21 +1,22 @@
use crate::playbar::PlayBar;
-use crate::playstatus::PlayStatus;
+use crate::playbar::CustomTitle;
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::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! {
@@ -24,7 +25,7 @@ pub fn App() -> impl IntoView {
// sets the document title
-
+
// content for this welcome page
impl IntoView {
}>
- }>
+ }>
+
+
@@ -53,12 +56,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! {
@@ -66,8 +69,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
new file mode 100644
index 0000000..401979d
--- /dev/null
+++ b/src/artistdata.rs
@@ -0,0 +1,34 @@
+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,
+ /// 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 DashboardTile for ArtistData {
+ fn image_path(&self) -> String {
+ self.image_path.clone()
+ }
+
+ fn title(&self) -> String {
+ self.name.clone()
+ }
+
+ fn link(&self) -> String {
+ format!("/artist/{}", self.id)
+ }
+
+ fn description(&self) -> Option {
+ Some("Artist".to_string())
+ }
+}
diff --git a/src/auth.rs b/src/auth.rs
index f0ad590..0019c1e 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 f0055e4..e581101 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -1,10 +1,11 @@
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::*;
+use leptos_meta::Title;
use leptos::*;
use leptos_icons::*;
use leptos_use::{utils::Pausable, use_interval_fn};
@@ -39,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 {
@@ -60,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);
@@ -84,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() {
@@ -108,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;
});
@@ -125,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);
@@ -138,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
@@ -159,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");
}
@@ -169,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");
}
@@ -184,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
@@ -247,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())
@@ -286,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() {
@@ -399,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::
();
@@ -411,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");
}
@@ -437,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
@@ -460,20 +467,37 @@ fn QueueToggle(status: RwSignal) -> impl IntoView {
}
}
+/// Renders the title of the page based on the currently playing song
+#[component]
+pub fn CustomTitle() -> impl IntoView {
+ let title = create_memo(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")
+ })
+ })
+ });
+ view! {
+
+ }
+}
+
/// 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");
}
@@ -482,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");
}
@@ -500,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);
}
});
@@ -643,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 61e263f..a70707f 100644
--- a/src/songdata.rs
+++ b/src/songdata.rs
@@ -1,10 +1,13 @@
use crate::models::{Album, Artist, Song};
+use crate::components::dashboard_tile::DashboardTile;
-use time::Date;
+use serde::{Serialize, Deserialize};
+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(Serialize, Deserialize, Clone)]
pub struct SongData {
/// Song id
pub id: i32,
@@ -19,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,
@@ -47,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
@@ -60,3 +62,21 @@ impl TryInto for SongData {
})
}
}
+
+impl DashboardTile for SongData {
+ fn image_path(&self) -> String {
+ self.image_path.clone()
+ }
+
+ fn title(&self) -> String {
+ self.title.clone()
+ }
+
+ fn link(&self) -> String {
+ format!("/song/{}", self.id)
+ }
+
+ fn description(&self) -> Option {
+ Some(format!("Song • {}", Artist::display_list(&self.artists)))
+ }
+}
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 387dfcd..59faaeb 100644
--- a/src/users.rs
+++ b/src/users.rs
@@ -128,3 +128,15 @@ pub async fn get_user(username_or_email: String) -> Result