Merge pull request 'Implement history' (#98) from 41-implement-history into main
Reviewed-on: LibreTunes/LibreTunes#98
This commit is contained in:
44
src/api/history.rs
Normal file
44
src/api/history.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use std::time::SystemTime;
|
||||
use leptos::*;
|
||||
use crate::models::HistoryEntry;
|
||||
use crate::models::Song;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::database::get_db_conn;
|
||||
use crate::auth::get_user;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the history of the current user.
|
||||
#[server(endpoint = "history/get")]
|
||||
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, ServerFnError> {
|
||||
let user = get_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
let history = user.get_history(limit, db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting history: {}", e)))?;
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
/// Get the listen dates and songs of the current user.
|
||||
#[server(endpoint = "history/get_songs")]
|
||||
pub async fn get_history_songs(limit: Option<i64>) -> Result<Vec<(SystemTime, Song)>, ServerFnError> {
|
||||
let user = get_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
let songs = user.get_history_songs(limit, db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting history songs: {}", e)))?;
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// Add a song to the history of the current user.
|
||||
#[server(endpoint = "history/add")]
|
||||
pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> {
|
||||
let user = get_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
user.add_history(song_id, db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error adding history: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
@ -1 +1,2 @@
|
||||
pub mod history;
|
||||
pub mod songs;
|
||||
|
157
src/models.rs
157
src/models.rs
@ -46,6 +46,146 @@ pub struct User {
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Get the history of songs listened to by this user from the database
|
||||
///
|
||||
/// The returned history will be ordered by date in descending order,
|
||||
/// and a limit of N will select the N most recent entries.
|
||||
/// The `id` field of this user must be present (Some) to get history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - An optional limit on the number of history entries to return
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
|
||||
/// A result indicating success with a vector of history entries, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_history(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
|
||||
Result<Vec<HistoryEntry>, Box<dyn Error>> {
|
||||
use crate::schema::song_history::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
|
||||
|
||||
let my_history =
|
||||
if let Some(limit) = limit {
|
||||
song_history
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history
|
||||
.filter(user_id.eq(my_id))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
Ok(my_history)
|
||||
}
|
||||
|
||||
/// Get the history of songs listened to by this user from the database
|
||||
///
|
||||
/// The returned history will be ordered by date in descending order,
|
||||
/// and a limit of N will select the N most recent entries.
|
||||
/// The `id` field of this user must be present (Some) to get history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - An optional limit on the number of history entries to return
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
|
||||
/// A result indicating success with a vector of listen dates and songs, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_history_songs(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
|
||||
Result<Vec<(SystemTime, Song)>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_history::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
|
||||
|
||||
let my_history =
|
||||
if let Some(limit) = limit {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.select((date, songs::all_columns()))
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.select((date, songs::all_columns()))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
Ok(my_history)
|
||||
}
|
||||
|
||||
/// Add a song to this user's history in the database
|
||||
///
|
||||
/// The date of the history entry will be the current time
|
||||
/// The `id` field of this user must be present (Some) to add history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `song_id` - The id of the song to add to this user's history
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn add_history(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::song_history;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to add history")?;
|
||||
|
||||
diesel::insert_into(song_history::table)
|
||||
.values((song_history::user_id.eq(my_id), song_history::song_id.eq(song_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this user has listened to a song
|
||||
///
|
||||
/// The `id` field of this user must be present (Some) to check history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `song_id` - The id of the song to check if this user has listened to
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<bool, Box<dyn Error>>` - A result indicating success with a boolean value, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn has_listened_to(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_history::{self, user_id};
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to check history")?;
|
||||
|
||||
let has_listened = song_history::table
|
||||
.filter(user_id.eq(my_id))
|
||||
.filter(song_history::song_id.eq(song_id))
|
||||
.first::<HistoryEntry>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(has_listened)
|
||||
}
|
||||
|
||||
/// Like or unlike a song for this user
|
||||
/// If likeing a song, remove dislike if it exists
|
||||
#[cfg(feature = "ssr")]
|
||||
@ -469,3 +609,20 @@ impl Song {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for a history entry
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::song_history))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
/// A unique id for the history entry
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
pub id: Option<i32>,
|
||||
/// The id of the user who listened to the song
|
||||
pub user_id: i32,
|
||||
/// The date the song was listened to
|
||||
pub date: SystemTime,
|
||||
/// The id of the song that was listened to
|
||||
pub song_id: i32,
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ use leptos::html::{Audio, Div};
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::{utils::Pausable, use_interval_fn};
|
||||
|
||||
/// Width and height of the forward/backward skip buttons
|
||||
const SKIP_BTN_SIZE: &str = "3.5em";
|
||||
@ -22,6 +23,9 @@ const MIN_SKIP_BACK_TIME: f64 = 5.0;
|
||||
/// How many seconds to skip forward/backward when the user presses the arrow keys
|
||||
const ARROW_KEY_SKIP_TIME: f64 = 5.0;
|
||||
|
||||
/// Threshold in seconds for considering when the user has listened to a song, for adding it to the history
|
||||
const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
|
||||
|
||||
// TODO Handle errors better, when getting audio HTML element and when playing/pausing audio
|
||||
|
||||
/// Get the current time and duration of the current song, if available
|
||||
@ -532,6 +536,39 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
});
|
||||
});
|
||||
|
||||
let current_song_id = create_memo(move |_| {
|
||||
status.with(|status| {
|
||||
status.queue.front().map(|song| song.id)
|
||||
})
|
||||
});
|
||||
|
||||
// Track the last song that was added to the history to prevent duplicates
|
||||
let last_history_song_id = create_rw_signal(None);
|
||||
|
||||
let Pausable {
|
||||
is_active: hist_timeout_pending,
|
||||
resume: resume_hist_timeout,
|
||||
pause: pause_hist_timeout,
|
||||
..
|
||||
} = use_interval_fn(move || {
|
||||
if last_history_song_id.get_untracked() == current_song_id.get_untracked() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(current_song_id) = current_song_id.get_untracked() {
|
||||
last_history_song_id.set(Some(current_song_id));
|
||||
|
||||
spawn_local(async move {
|
||||
if let Err(e) = crate::api::history::add_history(current_song_id).await {
|
||||
error!("Error adding song {} to history: {}", current_song_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, HISTORY_LISTEN_THRESHOLD * 1000);
|
||||
|
||||
// Initially pause the timeout, since the audio starts off paused
|
||||
pause_hist_timeout();
|
||||
|
||||
let on_play = move |_| {
|
||||
log!("Audio playing");
|
||||
status.update(|status| status.playing = true);
|
||||
@ -540,6 +577,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let on_pause = move |_| {
|
||||
log!("Audio paused");
|
||||
status.update(|status| status.playing = false);
|
||||
pause_hist_timeout();
|
||||
};
|
||||
|
||||
let on_time_update = move |_| {
|
||||
@ -557,6 +595,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
error!("Unable to update time: Audio element not available");
|
||||
}
|
||||
});
|
||||
|
||||
// If time is updated, audio is playing, so make sure the history timeout is running
|
||||
if !hist_timeout_pending.get_untracked() {
|
||||
resume_hist_timeout();
|
||||
}
|
||||
};
|
||||
|
||||
let on_end = move |_| {
|
||||
|
@ -53,6 +53,15 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_history (id) {
|
||||
id -> Int4,
|
||||
user_id -> Int4,
|
||||
date -> Timestamp,
|
||||
song_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_likes (song_id, user_id) {
|
||||
song_id -> Int4,
|
||||
@ -90,6 +99,8 @@ diesel::joinable!(song_artists -> artists (artist_id));
|
||||
diesel::joinable!(song_artists -> songs (song_id));
|
||||
diesel::joinable!(song_dislikes -> songs (song_id));
|
||||
diesel::joinable!(song_dislikes -> users (user_id));
|
||||
diesel::joinable!(song_history -> songs (song_id));
|
||||
diesel::joinable!(song_history -> users (user_id));
|
||||
diesel::joinable!(song_likes -> songs (song_id));
|
||||
diesel::joinable!(song_likes -> users (user_id));
|
||||
diesel::joinable!(songs -> albums (album_id));
|
||||
@ -102,6 +113,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
friendships,
|
||||
song_artists,
|
||||
song_dislikes,
|
||||
song_history,
|
||||
song_likes,
|
||||
songs,
|
||||
users,
|
||||
|
Reference in New Issue
Block a user