This commit is contained in:
JMARyA 2024-10-06 01:12:26 +02:00
parent 268ab4cc76
commit 334790da70
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
9 changed files with 366 additions and 37 deletions

View file

@ -9,12 +9,15 @@ use super::track::Track;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Album { pub struct Album {
/// Unique identifier for the album in the database.
pub id: uuid::Uuid, pub id: uuid::Uuid,
/// Human-readable title of the album.
pub title: String, pub title: String,
/// Optional foreign key referencing the artist ID. If present, this indicates that this album is associated with a specific artist.
pub artist: Option<uuid::Uuid>, pub artist: Option<uuid::Uuid>,
} }
impl Album { impl Album {
/// Creates a new album in the database with the given `title` and optional `artist`.
pub async fn create(title: &str, artist: Option<uuid::Uuid>) -> Self { pub async fn create(title: &str, artist: Option<uuid::Uuid>) -> Self {
sqlx::query_as("INSERT INTO album (title, artist) VALUES ($1, $2) RETURNING *") sqlx::query_as("INSERT INTO album (title, artist) VALUES ($1, $2) RETURNING *")
.bind(title) .bind(title)
@ -24,6 +27,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Retrieves a list of all albums in the database.
pub async fn find_all() -> Vec<Self> { pub async fn find_all() -> Vec<Self> {
sqlx::query_as("SELECT * FROM album") sqlx::query_as("SELECT * FROM album")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -31,6 +35,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Finds an album in the database by its `title` and optional `artist`.
pub async fn find(title: &str, artist: Option<uuid::Uuid>) -> Option<Self> { pub async fn find(title: &str, artist: Option<uuid::Uuid>) -> Option<Self> {
sqlx::query_as("SELECT * FROM album WHERE title = $1 AND artist = $2") sqlx::query_as("SELECT * FROM album WHERE title = $1 AND artist = $2")
.bind(title) .bind(title)
@ -40,6 +45,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Retrieves a list of albums from the database where the specified `column` matches a regular expression.
pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> { pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> {
sqlx::query_as(&format!("SELECT * FROM album WHERE {col} ~* $1")) sqlx::query_as(&format!("SELECT * FROM album WHERE {col} ~* $1"))
.bind(query) .bind(query)
@ -48,6 +54,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Retrieves a list of albums from the database associated with the specified `artist`.
pub async fn find_of_artist(artist: &uuid::Uuid) -> Vec<Self> { pub async fn find_of_artist(artist: &uuid::Uuid) -> Vec<Self> {
sqlx::query_as("SELECT * FROM album WHERE artist = $1") sqlx::query_as("SELECT * FROM album WHERE artist = $1")
.bind(artist) .bind(artist)
@ -56,6 +63,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Deletes an album from the database.
pub async fn remove(&self) { pub async fn remove(&self) {
sqlx::query("DELETE FROM album WHERE id = $1") sqlx::query("DELETE FROM album WHERE id = $1")
.bind(self.id) .bind(self.id)
@ -64,6 +72,7 @@ impl Album {
.unwrap(); .unwrap();
} }
/// Retrieves an album from the database by its `id`.
pub async fn get(id: &uuid::Uuid) -> Option<Self> { pub async fn get(id: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM album WHERE id = $1") sqlx::query_as("SELECT * FROM album WHERE id = $1")
.bind(id) .bind(id)
@ -72,6 +81,7 @@ impl Album {
.unwrap() .unwrap()
} }
/// Retrieves a list of tracks from the database associated with the specified `album`.
pub async fn get_tracks_of_album(album: &uuid::Uuid) -> Vec<Track> { pub async fn get_tracks_of_album(album: &uuid::Uuid) -> Vec<Track> {
sqlx::query_as("SELECT * FROM track WHERE album = $1") sqlx::query_as("SELECT * FROM track WHERE album = $1")
.bind(album) .bind(album)
@ -80,9 +90,9 @@ impl Album {
.unwrap() .unwrap()
} }
/// Returns the cover image of an album, or `None` if it doesn't exist. /// Retrieves the cover image of an album, or `None` if it doesn't exist.
/// ///
/// This method first retrieves the list of tracks in the album, then looks for a file named "cover.{ext}" where {ext} is one of png, jpg, jpeg, avif. The first existing cover file found will be returned. /// This method first retrieves a list of tracks in the album, then looks for a file named `cover.{ext}` where `{ext}` is one of `png`, `jpg`, `jpeg`, `avif`. The first existing cover file found will be returned.
pub async fn get_cover(&self) -> Option<String> { pub async fn get_cover(&self) -> Option<String> {
let track_path = Self::get_tracks_of_album(&self.id) let track_path = Self::get_tracks_of_album(&self.id)
.await .await

View file

@ -9,11 +9,13 @@ use super::track::Track;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Artist { pub struct Artist {
/// Unique identifier for the artist
pub id: uuid::Uuid, pub id: uuid::Uuid,
/// Name of the artist
pub name: String, pub name: String,
} }
impl Artist { impl Artist {
/// Creates a new artist with the given name.
pub async fn create(name: &str) -> Self { pub async fn create(name: &str) -> Self {
sqlx::query_as("INSERT INTO artist (name) VALUES ($1) RETURNING id, name") sqlx::query_as("INSERT INTO artist (name) VALUES ($1) RETURNING id, name")
.bind(name) .bind(name)
@ -22,6 +24,7 @@ impl Artist {
.unwrap() .unwrap()
} }
/// Retrieves an artist by their unique identifier.
pub async fn get(id: &uuid::Uuid) -> Option<Self> { pub async fn get(id: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM artist WHERE id = $1") sqlx::query_as("SELECT * FROM artist WHERE id = $1")
.bind(id) .bind(id)
@ -30,6 +33,7 @@ impl Artist {
.unwrap() .unwrap()
} }
/// Retrieves an artist by their name.
pub async fn find(name: &str) -> Option<Self> { pub async fn find(name: &str) -> Option<Self> {
sqlx::query_as("SELECT * FROM artist WHERE name = $1") sqlx::query_as("SELECT * FROM artist WHERE name = $1")
.bind(name) .bind(name)
@ -38,6 +42,7 @@ impl Artist {
.unwrap() .unwrap()
} }
/// Retrieves all artists in the database.
pub async fn find_all() -> Vec<Self> { pub async fn find_all() -> Vec<Self> {
sqlx::query_as("SELECT * FROM artist") sqlx::query_as("SELECT * FROM artist")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -45,6 +50,7 @@ impl Artist {
.unwrap() .unwrap()
} }
/// Searches for artists with columns matching a given regular expression.
pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> { pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> {
sqlx::query_as(&format!("SELECT * FROM artist WHERE {col} ~* $1")) sqlx::query_as(&format!("SELECT * FROM artist WHERE {col} ~* $1"))
.bind(query) .bind(query)
@ -53,6 +59,7 @@ impl Artist {
.unwrap() .unwrap()
} }
/// Deletes an artist.
pub async fn remove(&self) { pub async fn remove(&self) {
sqlx::query("DELETE FROM artist WHERE id = $1") sqlx::query("DELETE FROM artist WHERE id = $1")
.bind(self.id) .bind(self.id)
@ -61,7 +68,7 @@ impl Artist {
.unwrap(); .unwrap();
} }
/// Gets the image of an artist or `None` if it can't be found. /// Retrieves the image of an artist, or `None` if it cannot be found.
/// ///
/// This function gets a track from the artist. It then expects the folder structure to be `Artist/Album/Track.ext` and searches for an image file named `artist` in the artist folder. /// This function gets a track from the artist. It then expects the folder structure to be `Artist/Album/Track.ext` and searches for an image file named `artist` in the artist folder.
pub async fn get_image_of(id: &uuid::Uuid) -> Option<String> { pub async fn get_image_of(id: &uuid::Uuid) -> Option<String> {

View file

@ -2,25 +2,43 @@ use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow; use sqlx::prelude::FromRow;
use crate::{get_pg, library::user::User}; use crate::{get_pg, library::user::User};
/// Represents a user event in the database.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Event { pub struct Event {
/// Unique identifier for this event.
pub id: uuid::Uuid, pub id: uuid::Uuid,
/// Timestamp of when this event occurred.
pub time: chrono::DateTime<chrono::Utc>, pub time: chrono::DateTime<chrono::Utc>,
/// Type of event (e.g. play, stop).
pub kind: EventKind, pub kind: EventKind,
/// Username of the user who performed this action.
pub user: String, pub user: String,
/// ID of the track associated with this event.
pub track: uuid::Uuid, pub track: uuid::Uuid,
} }
/// Enum representing different types of events that can occur.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "event_kind", rename_all = "lowercase")] #[sqlx(type_name = "event_kind", rename_all = "lowercase")]
pub enum EventKind { pub enum EventKind {
/// A user started playing a track.
Play, Play,
/// A user finished playing a track.
Played, Played,
/// A user stopped playing a track.
Stop, Stop,
} }
impl Event { impl Event {
/// Creates a new event in the database with the given kind, user, and track.
///
/// Returns the newly created event object.
pub async fn create(kind: EventKind, user: &User, track: uuid::Uuid) -> Self { pub async fn create(kind: EventKind, user: &User, track: uuid::Uuid) -> Self {
sqlx::query_as("INSERT INTO events (kind, \"user\", track) VALUES ($1, $2, $3) RETURNING *") sqlx::query_as("INSERT INTO events (kind, \"user\", track) VALUES ($1, $2, $3) RETURNING *")
.bind(kind) .bind(kind)
@ -31,6 +49,9 @@ impl Event {
.unwrap() .unwrap()
} }
/// Retrieves the latest 300 events performed by a given user.
///
/// Returns a vector of event objects.
pub async fn get_latest_events_of(u: &User) -> Vec<Self> { pub async fn get_latest_events_of(u: &User) -> Vec<Self> {
sqlx::query_as("SELECT * FROM events WHERE \"user\" = $1 ORDER BY time DESC LIMIT 300") sqlx::query_as("SELECT * FROM events WHERE \"user\" = $1 ORDER BY time DESC LIMIT 300")
.bind(&u.username) .bind(&u.username)

View file

@ -6,31 +6,40 @@ use serde::{Deserialize, Serialize};
pub struct AudioMetadata(pub serde_json::Value); pub struct AudioMetadata(pub serde_json::Value);
impl AudioMetadata { impl AudioMetadata {
/// Get a key from the JSON data if it exists.
fn get_key(&self, key: &str) -> Option<&str> { fn get_key(&self, key: &str) -> Option<&str> {
self.0.as_object()?.get(key)?.as_str() self.0.as_object()?.get(key)?.as_str()
} }
/// Get the title of the audio metadata if it exists.
pub fn title(&self) -> Option<&str> { pub fn title(&self) -> Option<&str> {
self.get_key("title") self.get_key("title")
} }
/// Get the artist of the audio metadata if it exists.
pub fn artist(&self) -> Option<&str> { pub fn artist(&self) -> Option<&str> {
self.get_key("artist") self.get_key("artist")
} }
/// Get the album of the audio metadata if it exists.
pub fn album(&self) -> Option<&str> { pub fn album(&self) -> Option<&str> {
self.get_key("album") self.get_key("album")
} }
/// Get the track number of the audio metadata if it exists.
pub fn track_number(&self) -> Option<usize> { pub fn track_number(&self) -> Option<usize> {
// Note: This will return an error if the track number is not a valid integer
self.get_key("tracknumber").map(|x| x.parse().ok())? self.get_key("tracknumber").map(|x| x.parse().ok())?
} }
} }
/// Get the audio metadata for a given file path, or None if it fails.
pub fn get_metadata(file_path: &str) -> Option<AudioMetadata> { pub fn get_metadata(file_path: &str) -> Option<AudioMetadata> {
// Simply wrap the JSON data in an `AudioMetadata` instance
Some(AudioMetadata(get_metadata_json(file_path)?)) Some(AudioMetadata(get_metadata_json(file_path)?))
} }
/// Get the JSON data for a given file path using the `extract_metadata.py` script, or None if it fails.
pub fn get_metadata_json(file_path: &str) -> Option<serde_json::Value> { pub fn get_metadata_json(file_path: &str) -> Option<serde_json::Value> {
let output = std::process::Command::new("python3") let output = std::process::Command::new("python3")
.arg("./extract_metadata.py") .arg("./extract_metadata.py")

View file

@ -34,41 +34,59 @@ fn is_music_file(path: &Path) -> bool {
pub struct Libary { pub struct Libary {
root_dir: PathBuf, root_dir: PathBuf,
} }
impl Libary { impl Libary {
/// Creates a new instance of the Libary struct with the specified `root_dir`.
pub const fn new(root_dir: PathBuf) -> Self { pub const fn new(root_dir: PathBuf) -> Self {
Self { root_dir } Self { root_dir }
} }
/// Finds or creates an artist by their name and returns its ID.
///
/// If the artist already exists, this method returns their ID. Otherwise, it creates a new artist record and returns its ID.
pub async fn find_or_create_artist(&self, artist: &str) -> uuid::Uuid { pub async fn find_or_create_artist(&self, artist: &str) -> uuid::Uuid {
if let Some(artist) = Artist::find(artist).await { if let Some(artist) = Artist::find(artist).await {
// Return the existing artist's ID.
artist.id artist.id
} else { } else {
// Create a new artist record with the given name and return its ID.
Artist::create(artist).await.id Artist::create(artist).await.id
} }
} }
/// Finds or creates an album by its title and returns its ID.
///
/// This method takes an optional `artist_id` parameter, which specifies the
/// artist to associate with the new album. If no `artist_id` is provided,
/// the album will be created without an associated artist.
pub async fn find_or_create_album( pub async fn find_or_create_album(
&self, &self,
album: &str, album: &str,
artist_id: Option<uuid::Uuid>, artist_id: Option<uuid::Uuid>,
) -> uuid::Uuid { ) -> uuid::Uuid {
if let Some(album) = Album::find(album, artist_id).await { if let Some(album) = Album::find(album, artist_id).await {
// Return the existing album's ID.
album.id album.id
} else { } else {
// Create a new album record with the given title and return its ID.
Album::create(album, artist_id).await.id Album::create(album, artist_id).await.id
} }
} }
/// Adds a music file at the specified `path` to the library.
///
/// This method searches for the path in the existing library records. If it's
/// already present, no action is taken. Otherwise, it extracts metadata from
/// the file and creates a new track record with associated artist and album IDs,
/// if available.
pub async fn add_path_to_library(&self, path: &str) { pub async fn add_path_to_library(&self, path: &str) {
// search for path already present // Check if the path is already present in the library records.
if Track::of_path(path).await.is_some() { if Track::of_path(path).await.is_some() {
return; return;
} }
log::info!("Adding {path} to library"); log::info!("Adding {path} to library");
// add track to library // Extract metadata from the file at the specified path.
let metadata = metadata::get_metadata(path); let metadata = metadata::get_metadata(path);
let mut entry = json!({ let mut entry = json!({
@ -76,6 +94,7 @@ impl Libary {
}); });
if let Some(meta) = &metadata { if let Some(meta) = &metadata {
// If artist information is available, create or retrieve its ID and associate it with this track record.
if let Some(artist) = meta.artist() { if let Some(artist) = meta.artist() {
let artist_id = self.find_or_create_artist(artist).await; let artist_id = self.find_or_create_artist(artist).await;
entry entry
@ -86,6 +105,7 @@ impl Libary {
log::warn!("{path} has no artist"); log::warn!("{path} has no artist");
} }
// If album information is available, create or retrieve its ID and associate it with this track record.
if let Some(album) = meta.album() { if let Some(album) = meta.album() {
let album_id = self let album_id = self
.find_or_create_album( .find_or_create_album(
@ -106,6 +126,7 @@ impl Libary {
log::warn!("{path} has no album and will be treated as single"); log::warn!("{path} has no album and will be treated as single");
} }
// Extract the title of this track from the metadata.
if let Some(title) = meta.title() { if let Some(title) = meta.title() {
entry entry
.as_object_mut() .as_object_mut()
@ -114,6 +135,7 @@ impl Libary {
} }
} }
// Add any available metadata to the track record.
if let Some(metadata) = metadata { if let Some(metadata) = metadata {
entry entry
.as_object_mut() .as_object_mut()
@ -121,7 +143,7 @@ impl Libary {
.insert("meta".into(), metadata.0); .insert("meta".into(), metadata.0);
} }
// if no title in metadata use file name // If no title is specified in the metadata, use the file name as the title.
if entry.as_object().unwrap().get("title").is_none() { if entry.as_object().unwrap().get("title").is_none() {
entry.as_object_mut().unwrap().insert( entry.as_object_mut().unwrap().insert(
"title".into(), "title".into(),
@ -134,9 +156,11 @@ impl Libary {
); );
} }
// Create a new track record with the extracted metadata and add it to the library.
Track::create(entry.as_object().unwrap()).await; Track::create(entry.as_object().unwrap()).await;
} }
/// Retrieves all artists from the database.
pub async fn get_artists(&self) -> Vec<Artist> { pub async fn get_artists(&self) -> Vec<Artist> {
sqlx::query_as("SELECT * FROM artist") sqlx::query_as("SELECT * FROM artist")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -144,6 +168,7 @@ impl Libary {
.unwrap() .unwrap()
} }
/// Retrieves all albums for a given artist ID.
pub async fn get_albums_by_artist(&self, artist: &uuid::Uuid) -> Vec<Album> { pub async fn get_albums_by_artist(&self, artist: &uuid::Uuid) -> Vec<Album> {
sqlx::query_as("SELECT * FROM album WHERE artist = $1") sqlx::query_as("SELECT * FROM album WHERE artist = $1")
.bind(artist) .bind(artist)
@ -152,6 +177,7 @@ impl Libary {
.unwrap() .unwrap()
} }
/// Retrieves all singles (tracks with no album) for a given artist ID.
pub async fn get_singles_by_artist(&self, artist: &uuid::Uuid) -> Vec<Track> { pub async fn get_singles_by_artist(&self, artist: &uuid::Uuid) -> Vec<Track> {
sqlx::query_as("SELECT * FROM track WHERE album IS NULL AND artist = $1") sqlx::query_as("SELECT * FROM track WHERE album IS NULL AND artist = $1")
.bind(artist) .bind(artist)
@ -160,26 +186,43 @@ impl Libary {
.unwrap() .unwrap()
} }
/// Retrieves an album record by its ID, returning None if not found.
pub async fn get_album_by_id(&self, album: &uuid::Uuid) -> Option<Album> { pub async fn get_album_by_id(&self, album: &uuid::Uuid) -> Option<Album> {
Album::get(album).await Album::get(album).await
} }
/// Retrieves a track record by its ID, returning None if not found.
pub async fn get_track_by_id(&self, track_id: &uuid::Uuid) -> Option<Track> { pub async fn get_track_by_id(&self, track_id: &uuid::Uuid) -> Option<Track> {
Track::get(track_id).await Track::get(track_id).await
} }
/// Reloads metadata for a given track ID.
///
/// This method retrieves the current metadata associated with the track,
/// updates it if necessary (e.g., artist or album is missing), and then
/// persists the updated metadata to the database.
pub async fn reload_metadata(&self, track_id: &uuid::Uuid) -> Result<(), ()> { pub async fn reload_metadata(&self, track_id: &uuid::Uuid) -> Result<(), ()> {
// Retrieve the track record from the database.
let track = Track::get(track_id).await.ok_or_else(|| ())?; let track = Track::get(track_id).await.ok_or_else(|| ())?;
// Get the path associated with the track (e.g., file system location).
let path = &track.path; let path = &track.path;
log::info!("Rescanning metadata for {path}"); log::info!("Rescanning metadata for {path}");
// Retrieve the current metadata for the track.
let metadata = metadata::get_metadata(path); let metadata = metadata::get_metadata(path);
// Create an update object to store changes to the metadata.
let mut update = json!({}); let mut update = json!({});
// If metadata is available, extract artist and album information.
if let Some(meta) = &metadata { if let Some(meta) = &metadata {
// Get the artist associated with the track (if present).
if let Some(artist) = meta.artist() { if let Some(artist) = meta.artist() {
// Create or retrieve an artist record in the database.
let artist_id = self.find_or_create_artist(artist).await; let artist_id = self.find_or_create_artist(artist).await;
// Add the artist ID to the update object.
update update
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
@ -188,7 +231,10 @@ impl Libary {
log::warn!("{path} has no artist"); log::warn!("{path} has no artist");
} }
// Get the album associated with the track (if present).
if let Some(album) = meta.album() { if let Some(album) = meta.album() {
// Create or retrieve an album record in the database, using
// the update object to pass additional metadata information.
let album_id = self let album_id = self
.find_or_create_album( .find_or_create_album(
album, album,
@ -199,6 +245,8 @@ impl Libary {
.map(|x| uuid::Uuid::from_str(x.as_str().unwrap()).unwrap()), .map(|x| uuid::Uuid::from_str(x.as_str().unwrap()).unwrap()),
) )
.await; .await;
// Add the album ID to the update object.
update update
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
@ -207,6 +255,7 @@ impl Libary {
log::warn!("{path} has no album and will be treated as single"); log::warn!("{path} has no album and will be treated as single");
} }
// Get the track title (if present).
if let Some(title) = meta.title() { if let Some(title) = meta.title() {
update update
.as_object_mut() .as_object_mut()
@ -215,6 +264,7 @@ impl Libary {
} }
} }
// Add any remaining metadata to the update object.
if let Some(metadata) = metadata { if let Some(metadata) = metadata {
update update
.as_object_mut() .as_object_mut()
@ -222,7 +272,7 @@ impl Libary {
.insert("meta".into(), metadata.0); .insert("meta".into(), metadata.0);
} }
// if no title in metadata use file name // If no track title is available, use the file name as a fallback.
if update.as_object().unwrap().get("title").is_none() { if update.as_object().unwrap().get("title").is_none() {
update.as_object_mut().unwrap().insert( update.as_object_mut().unwrap().insert(
"title".into(), "title".into(),
@ -235,27 +285,34 @@ impl Libary {
); );
} }
// Apply the updated metadata to the track record.
track.update(&update).await.unwrap(); track.update(&update).await.unwrap();
Ok(()) Ok(())
} }
/// Cleans up lost files from the database.
///
/// This method iterates over all tracks, albums, and artists in the
/// database and removes any records that no longer exist on disk.
pub async fn clean_lost_files(&self) { pub async fn clean_lost_files(&self) {
// tracks // Clean up lost track records.
for track in Track::find_all().await { for track in Track::find_all().await {
if !std::path::Path::new(&track.path).exists() { if !std::path::Path::new(&track.path).exists() {
log::info!("Cleaning lost {}", track.path); log::info!("Cleaning lost {}", track.path);
track.remove().await; track.remove().await;
} }
} }
// albums
// Clean up lost album records.
for album in Album::find_all().await { for album in Album::find_all().await {
if Track::find_of_album(&album.id).await.is_empty() { if Track::find_of_album(&album.id).await.is_empty() {
log::info!("Cleaning album {} with no tracks", album.title); log::info!("Cleaning album {} with no tracks", album.title);
album.remove().await; album.remove().await;
} }
} }
// artists
// Clean up lost artist records.
for artist in Artist::find_all().await { for artist in Artist::find_all().await {
if Track::find_first_of_artist(&artist.id).await.is_none() if Track::find_first_of_artist(&artist.id).await.is_none()
&& Album::find_of_artist(&artist.id).await.is_empty() && Album::find_of_artist(&artist.id).await.is_empty()
@ -266,18 +323,22 @@ impl Libary {
} }
} }
/// Rescan the entire library.
pub async fn rescan(&self, cache: &RouteCache) { pub async fn rescan(&self, cache: &RouteCache) {
cache.invalidate("albums", "latest").await; cache.invalidate("albums", "latest").await;
log::info!("Rescanning library"); log::info!("Rescanning library");
for entry in WalkDir::new(self.root_dir.clone()) for entry in WalkDir::new(self.root_dir.clone())
.follow_links(true) .follow_links(true)
.into_iter() .into_iter()
.filter_map(std::result::Result::ok) .filter_map(std::result::Result::ok)
{ {
let path = entry.path(); let path = entry.path();
if path.is_file() && is_music_file(path) { if path.is_file() && is_music_file(path) {
let path = path.to_string_lossy().to_string(); let path = path.to_string_lossy().to_string();
self.add_path_to_library(&path).await; self.add_path_to_library(&path).await;
} }
} }

View file

@ -5,12 +5,23 @@ use crate::{get_pg, library::user::User};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Playlist { pub struct Playlist {
// Unique identifier of the playlist
pub id: uuid::Uuid, pub id: uuid::Uuid,
// Optional string representation of the playlist's ID
#[sqlx(default)] #[sqlx(default)]
pub id_str: Option<String>, pub id_str: Option<String>,
// Owner of the playlist
pub owner: String, pub owner: String,
// Human-readable title of the playlist
pub title: String, pub title: String,
// Level of visibility for this playlist (public, private, etc.)
pub visibility: Visibility, pub visibility: Visibility,
// IDs of tracks that belong to this playlist
pub tracks: Vec<uuid::Uuid>, pub tracks: Vec<uuid::Uuid>,
} }
@ -20,8 +31,15 @@ pub enum Visibility {
Private, Private,
Public, Public,
} }
impl Playlist { impl Playlist {
/// Creates a new playlist with the given owner, title, visibility, and tracks.
///
/// # Arguments
///
/// * `owner`: The user who owns the playlist.
/// * `title`: The human-readable title of the playlist.
/// * `visibility`: The level of visibility for this playlist (public, private, etc.).
/// * `tracks`: A list of track IDs that belong to this playlist.
pub async fn create( pub async fn create(
owner: User, owner: User,
title: &str, title: &str,
@ -29,33 +47,71 @@ impl Playlist {
tracks: &[uuid::Uuid], tracks: &[uuid::Uuid],
) -> Option<Self> { ) -> Option<Self> {
sqlx::query_as("INSERT INTO playlist (owner, title, visibility, tracks) VALUES ($1, $2, $3, $4) RETURNING *") sqlx::query_as("INSERT INTO playlist (owner, title, visibility, tracks) VALUES ($1, $2, $3, $4) RETURNING *")
.bind(owner.username) .bind(owner.username)
.bind(title) .bind(title)
.bind(visibility) .bind(visibility)
.bind(tracks) .bind(tracks)
.fetch_one(get_pg!()).await.ok() .fetch_one(get_pg!())
.await.ok()
} }
/// Finds playlists where a given column matches a regular expression.
///
/// # Arguments
///
/// * `col`: The name of the column to search in (e.g., "title", "owner").
/// * `query`: A string containing the regular expression pattern.
///
/// # Returns
///
/// A list of `Playlist` instances that match the query.
pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> { pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> {
sqlx::query_as(&format!("SELECT * FROM playlist WHERE {col} ~* $1")) sqlx::query_as(&format!("SELECT * FROM playlist WHERE {col} ~* $1"))
// Bind the regular expression pattern as a string
.bind(query) .bind(query)
// Execute the query using the PostgreSQL connection pool and fetch all results
.fetch_all(get_pg!()) .fetch_all(get_pg!())
.await .await
// Return the result as a list of `Playlist` instances (unwrap() is used here, but consider adding error handling)
.unwrap() .unwrap()
} }
/// Retrieves a playlist by its unique ID.
///
/// # Arguments
///
/// * `id`: The UUID of the playlist to retrieve.
///
/// # Returns
///
/// An optional `Playlist` instance if retrieval was successful; otherwise, `None`.
pub async fn get(id: &uuid::Uuid) -> Option<Self> { pub async fn get(id: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM playlist WHERE id = $1") sqlx::query_as("SELECT * FROM playlist WHERE id = $1")
// Bind the UUID of the playlist to retrieve
.bind(id) .bind(id)
// Execute the query using the PostgreSQL connection pool and fetch an optional result
.fetch_optional(get_pg!()) .fetch_optional(get_pg!())
// Return the result as an optional `Playlist` instance (unwrap() is used here, but consider adding error handling)
.await .await
.unwrap() .unwrap()
} }
/// Retrieves all playlists owned by a given user.
///
/// # Arguments
///
/// * `u`: The user who owns the playlists to retrieve.
///
/// # Returns
///
/// A list of `Playlist` instances that belong to the given user.
pub async fn of(u: &User) -> Vec<Self> { pub async fn of(u: &User) -> Vec<Self> {
sqlx::query_as("SELECT * FROM playlist WHERE owner = $1") sqlx::query_as("SELECT * FROM playlist WHERE owner = $1")
// Bind the username of the user who owns the playlists
.bind(&u.username) .bind(&u.username)
// Execute the query using the PostgreSQL connection pool and fetch all results
.fetch_all(get_pg!()) .fetch_all(get_pg!())
// Return the result as a list of `Playlist` instances (unwrap() is used here, but consider adding error handling)
.await .await
.unwrap() .unwrap()
} }

View file

@ -5,33 +5,39 @@ use crate::route::ToAPI;
use super::{album::Album, artist::Artist, playlist::Playlist, track::Track}; use super::{album::Album, artist::Artist, playlist::Playlist, track::Track};
// Calculate a score for a given field based on its similarity to the search term and recency factor
fn calculate_score(field: &str, search_term: &str, date_added: Option<i64>) -> f64 { fn calculate_score(field: &str, search_term: &str, date_added: Option<i64>) -> f64 {
// Exact match bonus // Exact match bonus: assign a high score if the field exactly matches the search term
let exact_match_bonus = if field.eq_ignore_ascii_case(search_term) { let exact_match_bonus = if field.eq_ignore_ascii_case(search_term) {
10.0 10.0
} else { } else {
0.0 0.0
}; };
// String similarity score // String similarity score: calculate how similar the field is to the search term using string matching
let similarity_score = string_similarity(field, search_term); let similarity_score = string_similarity(field, search_term);
// Recency factor // Recency factor: assign a boost to recent items based on their age in days
let recency_score = if let Some(date) = date_added { let recency_score = if let Some(date) = date_added {
recency_factor(date) recency_factor(date)
} else { } else {
0.0 0.0 // Assign a low score for no date added
}; };
// Calculate total score // Calculate the total score by summing up the exact match bonus, string similarity score, and recency factor
exact_match_bonus + similarity_score + recency_score exact_match_bonus + similarity_score + recency_score
} }
// Calculate the similarity between two strings using character matching and length penalty
fn string_similarity(field: &str, search_term: &str) -> f64 { fn string_similarity(field: &str, search_term: &str) -> f64 {
// Initialize a counter for matching characters
let mut match_count = 0; let mut match_count = 0;
// Convert both strings to lowercase for case-insensitive comparison
let field_lower = field.to_lowercase(); let field_lower = field.to_lowercase();
let search_lower = search_term.to_lowercase(); let search_lower = search_term.to_lowercase();
// Iterate over the characters in the search term and count matches with the field
for (i, c) in search_lower.chars().enumerate() { for (i, c) in search_lower.chars().enumerate() {
if let Some(field_char) = field_lower.chars().nth(i) { if let Some(field_char) = field_lower.chars().nth(i) {
if field_char == c { if field_char == c {
@ -40,17 +46,26 @@ fn string_similarity(field: &str, search_term: &str) -> f64 {
} }
} }
// Calculate a penalty based on the length difference between the two strings
let length_diff_penalty = (field.len() as f64 - search_term.len() as f64).abs() * 0.1; let length_diff_penalty = (field.len() as f64 - search_term.len() as f64).abs() * 0.1;
// Calculate the base similarity score based on the number of matching characters and string lengths
let base_similarity = (match_count as f64 / search_term.len() as f64) * 8.0; let base_similarity = (match_count as f64 / search_term.len() as f64) * 8.0;
// Return the similarity score, capping it at 0 to prevent negative scores
(base_similarity - length_diff_penalty).max(0.0) (base_similarity - length_diff_penalty).max(0.0)
} }
// Calculate a recency factor based on how old an item is in days
fn recency_factor(date_added: i64) -> f64 { fn recency_factor(date_added: i64) -> f64 {
// Get the current timestamp
let current_time = chrono::Utc::now().timestamp(); let current_time = chrono::Utc::now().timestamp();
// Calculate the age of the item in seconds and convert it to days
let age_in_seconds = current_time - date_added; let age_in_seconds = current_time - date_added;
let age_in_days = age_in_seconds as f64 / 86400.0; // Convert to days let age_in_days = age_in_seconds as f64 / 86400.0; // Convert to days
// Assign a boost based on how old the item is, with recent items getting more points
if age_in_days < 30.0 { if age_in_days < 30.0 {
5.0 // Boost recent items 5.0 // Boost recent items
} else if age_in_days < 365.0 { } else if age_in_days < 365.0 {
@ -67,10 +82,11 @@ pub struct SearchResult {
score: f64, score: f64,
} }
// Perform a search across artists, albums, tracks, and playlists based on the query term
pub async fn search_for(query: String) -> Option<Vec<SearchResult>> { pub async fn search_for(query: String) -> Option<Vec<SearchResult>> {
let mut results: Vec<SearchResult> = Vec::new(); let mut results: Vec<SearchResult> = Vec::new();
// Add artist results // Search for artists matching the query term
for artist in Artist::find_regex_col("name", &query).await { for artist in Artist::find_regex_col("name", &query).await {
results.push(SearchResult { results.push(SearchResult {
kind: "artist".to_string(), kind: "artist".to_string(),
@ -79,7 +95,7 @@ pub async fn search_for(query: String) -> Option<Vec<SearchResult>> {
}); });
} }
// Add album results // Search for albums matching the query term
for album in Album::find_regex_col("title", &query).await { for album in Album::find_regex_col("title", &query).await {
results.push(SearchResult { results.push(SearchResult {
kind: "album".to_string(), kind: "album".to_string(),
@ -88,7 +104,7 @@ pub async fn search_for(query: String) -> Option<Vec<SearchResult>> {
}); });
} }
// Add track results // Search for tracks matching the query term
for track in Track::find_regex_col("title", &query).await { for track in Track::find_regex_col("title", &query).await {
results.push(SearchResult { results.push(SearchResult {
kind: "track".to_string(), kind: "track".to_string(),
@ -97,7 +113,7 @@ pub async fn search_for(query: String) -> Option<Vec<SearchResult>> {
}); });
} }
// Add playlist results // Search for playlists matching the query term
for playlist in Playlist::find_regex_col("title", &query).await { for playlist in Playlist::find_regex_col("title", &query).await {
results.push(SearchResult { results.push(SearchResult {
kind: "playlist".to_string(), kind: "playlist".to_string(),
@ -106,9 +122,8 @@ pub async fn search_for(query: String) -> Option<Vec<SearchResult>> {
}); });
} }
// Sort results by score (descending) // Sort the search results by their score in descending order
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal)); results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
// Return results
Some(results) Some(results)
} }

View file

@ -13,16 +13,34 @@ use super::{event::Event, metadata::AudioMetadata, user::User};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Track { pub struct Track {
/// The unique identifier of the track
pub id: uuid::Uuid, pub id: uuid::Uuid,
// The file path where the track is stored
pub path: String, pub path: String,
/// The title of the track
pub title: String, pub title: String,
/// The date when the track was added to the library
pub date_added: chrono::DateTime<chrono::Utc>, pub date_added: chrono::DateTime<chrono::Utc>,
/// Optional associated album
pub album: Option<uuid::Uuid>, pub album: Option<uuid::Uuid>,
/// Optional associated artist
pub artist: Option<uuid::Uuid>, pub artist: Option<uuid::Uuid>,
/// Optional metadata for the track
pub meta: Option<serde_json::Value>, pub meta: Option<serde_json::Value>,
} }
impl Track { impl Track {
/// Create a new track.
///
/// # Arguments
///
/// * `data` - The data to create the track with.
pub async fn create(data: &serde_json::Map<String, serde_json::Value>) { pub async fn create(data: &serde_json::Map<String, serde_json::Value>) {
sqlx::query( sqlx::query(
"INSERT INTO track (path, title, meta, album, artist) VALUES ($1, $2, $3, $4, $5)", "INSERT INTO track (path, title, meta, album, artist) VALUES ($1, $2, $3, $4, $5)",
@ -43,6 +61,15 @@ impl Track {
.unwrap(); .unwrap();
} }
/// Find a track by path.
///
/// # Arguments
///
/// * `path` - The path of the track to find.
///
/// # Returns
///
/// The found track, or None if no track is found.
pub async fn of_path(path: &str) -> Option<Self> { pub async fn of_path(path: &str) -> Option<Self> {
sqlx::query_as("SELECT * FROM track WHERE path = $1") sqlx::query_as("SELECT * FROM track WHERE path = $1")
.bind(path) .bind(path)
@ -51,6 +78,11 @@ impl Track {
.unwrap() .unwrap()
} }
/// Find all recently added tracks.
///
/// # Returns
///
/// A vector of recently added tracks.
pub async fn find_recently_added() -> Vec<Self> { pub async fn find_recently_added() -> Vec<Self> {
sqlx::query_as("SELECT * FROM track ORDER BY date_added DESC LIMIT 90") sqlx::query_as("SELECT * FROM track ORDER BY date_added DESC LIMIT 90")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -58,6 +90,15 @@ impl Track {
.unwrap() .unwrap()
} }
/// Find a track by ID.
///
/// # Arguments
///
/// * `id` - The ID of the track to find.
///
/// # Returns
///
/// The found track, or None if no track is found.
pub async fn get(id: &uuid::Uuid) -> Option<Self> { pub async fn get(id: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM track WHERE id = $1") sqlx::query_as("SELECT * FROM track WHERE id = $1")
.bind(id) .bind(id)
@ -66,6 +107,15 @@ impl Track {
.unwrap() .unwrap()
} }
/// Update a track.
///
/// # Arguments
///
/// * `update_set` - The update set to apply to the track.
///
/// # Returns
///
/// Some(()) if the update was successful, or None if not.
pub async fn update(&self, update_set: &serde_json::Value) -> Option<()> { pub async fn update(&self, update_set: &serde_json::Value) -> Option<()> {
let map = update_set.as_object()?; let map = update_set.as_object()?;
let artist = map let artist = map
@ -93,6 +143,11 @@ impl Track {
Some(()) Some(())
} }
/// Find all tracks.
///
/// # Returns
///
/// A vector of all tracks.
pub async fn find_all() -> Vec<Self> { pub async fn find_all() -> Vec<Self> {
sqlx::query_as("SELECT * FROM track") sqlx::query_as("SELECT * FROM track")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -100,6 +155,11 @@ impl Track {
.unwrap() .unwrap()
} }
/// Remove a track.
///
/// # Arguments
///
/// * `id` - The ID of the track to remove.
pub async fn remove(&self) { pub async fn remove(&self) {
sqlx::query("DELETE FROM track WHERE id = $1") sqlx::query("DELETE FROM track WHERE id = $1")
.bind(self.id) .bind(self.id)
@ -108,6 +168,15 @@ impl Track {
.unwrap(); .unwrap();
} }
/// Find tracks by album.
///
/// # Arguments
///
/// * `album` - The album to find tracks for.
///
/// # Returns
///
/// A vector of tracks with the specified album.
pub async fn find_of_album(album: &uuid::Uuid) -> Vec<Self> { pub async fn find_of_album(album: &uuid::Uuid) -> Vec<Self> {
sqlx::query_as("SELECT * FROM track WHERE album = $1") sqlx::query_as("SELECT * FROM track WHERE album = $1")
.bind(album) .bind(album)
@ -116,6 +185,18 @@ impl Track {
.unwrap() .unwrap()
} }
/// Retrieves a list of tracks that are the latest listened to for a given user.
///
/// This function fetches the latest events associated with the user and then filters them by track ID.
/// It uses these IDs to query the database for all tracks with matching IDs.
///
/// # Arguments
///
/// * `u`: The user object whose latest tracks are being retrieved.
///
/// # Returns
///
/// A vector of `Track` objects representing the latest tracks for the given user.
pub async fn get_latest_of_user(u: &User) -> Vec<Self> { pub async fn get_latest_of_user(u: &User) -> Vec<Self> {
let latest_events = Event::get_latest_events_of(u).await; let latest_events = Event::get_latest_events_of(u).await;
let mut ids = HashSet::new(); let mut ids = HashSet::new();
@ -133,17 +214,46 @@ impl Track {
.unwrap() .unwrap()
} }
/// Transcode audio to OPUS with `bitrate` /// Transcodes audio to OPUS format with the specified bitrate.
///
/// # Arguments
///
/// * `bitrate`: The desired bitrate for the transcoded audio.
///
/// # Returns
///
/// A `String` representing the path to the transceded audio file, or `None` if the transcoding failed.
pub fn get_opus(&self, bitrate: u32) -> Option<String> { pub fn get_opus(&self, bitrate: u32) -> Option<String> {
self.transcode("libopus", bitrate, "opus") self.transcode("libopus", bitrate, "opus")
} }
/// Transcode audio to AAC with `bitrate` /// Transcodes audio to AAC format with the specified bitrate.
///
/// # Arguments
///
/// * `bitrate`: The desired bitrate for the transcoded audio.
///
/// # Returns
///
/// A `String` representing the path to the transceded audio file, or `None` if the transcoding failed.
pub fn get_aac(&self, bitrate: u32) -> Option<String> { pub fn get_aac(&self, bitrate: u32) -> Option<String> {
self.transcode("aac", bitrate, "aac") self.transcode("aac", bitrate, "aac")
} }
/// Transcode audio /// Transcodes audio from its original format to the specified codec and extension.
///
/// This function creates a new directory for the transceded audio if it does not exist,
/// and then uses `ffmpeg` to perform the transcoding operation.
///
/// # Arguments
///
/// * `codec`: The desired codec for the transceded audio (e.g. "libopus", "aac").
/// * `bitrate`: The desired bitrate for the transceded audio.
/// * `ext`: The desired extension for the transceded audio file (e.g. "opus", "aac").
///
/// # Returns
///
/// A `String` representing the path to the transceded audio file, or `None` if the transcoding failed.
pub fn transcode(&self, codec: &str, bitrate: u32, ext: &str) -> Option<String> { pub fn transcode(&self, codec: &str, bitrate: u32, ext: &str) -> Option<String> {
let transcoded = format!("./data/transcode/{codec}/{bitrate}/{}.{ext}", self.id); let transcoded = format!("./data/transcode/{codec}/{bitrate}/{}.{ext}", self.id);
@ -173,6 +283,16 @@ impl Track {
Some(transcoded) Some(transcoded)
} }
/// Finds tracks in the database that match a regular expression pattern.
///
/// # Arguments
///
/// * `col`: The name of the column to filter on.
/// * `query`: The regular expression pattern to match against.
///
/// # Returns
///
/// A vector of `Track` objects representing the matching tracks.
pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> { pub async fn find_regex_col(col: &str, query: &str) -> Vec<Self> {
sqlx::query_as(&format!("SELECT * FROM track WHERE {col} ~* $1")) sqlx::query_as(&format!("SELECT * FROM track WHERE {col} ~* $1"))
.bind(query) .bind(query)
@ -181,7 +301,11 @@ impl Track {
.unwrap() .unwrap()
} }
/// Find tracks with no album or artist /// Retrieves a list of tracks with no album or artist associated.
///
/// # Returns
///
/// A vector of `Track` objects representing the orphaned tracks.
pub async fn get_orphans() -> Vec<Track> { pub async fn get_orphans() -> Vec<Track> {
sqlx::query_as("SELECT * FROM track WHERE artist IS NULL AND album IS NULL") sqlx::query_as("SELECT * FROM track WHERE artist IS NULL AND album IS NULL")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -189,6 +313,7 @@ impl Track {
.unwrap() .unwrap()
} }
/// Finds the first track associated with a given artist.
pub async fn find_first_of_artist(artist: &uuid::Uuid) -> Option<Self> { pub async fn find_first_of_artist(artist: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM track WHERE artist = $1 LIMIT 1") sqlx::query_as("SELECT * FROM track WHERE artist = $1 LIMIT 1")
.bind(artist) .bind(artist)
@ -197,6 +322,7 @@ impl Track {
.unwrap() .unwrap()
} }
/// Finds the first track of a given album.
pub async fn find_first_of_album(album: &uuid::Uuid) -> Option<Self> { pub async fn find_first_of_album(album: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM track WHERE album = $1 LIMIT 1") sqlx::query_as("SELECT * FROM track WHERE album = $1 LIMIT 1")
.bind(album) .bind(album)

View file

@ -17,19 +17,25 @@ fn gen_token(token_length: usize) -> String {
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User { pub struct User {
/// The username chosen by the user
pub username: String, pub username: String,
/// The hashed password for the user
pub password: String, pub password: String,
/// The role of the user
pub user_role: UserRole, pub user_role: UserRole,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")] #[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole { pub enum UserRole {
/// A regular user with limited permissions
Regular, Regular,
/// An admin user with full system privileges
Admin, Admin,
} }
impl User { impl User {
/// Find a user by their username
pub async fn find(username: &str) -> Option<Self> { pub async fn find(username: &str) -> Option<Self> {
sqlx::query_as("SELECT * FROM users WHERE username = $1") sqlx::query_as("SELECT * FROM users WHERE username = $1")
.bind(username) .bind(username)
@ -38,7 +44,11 @@ impl User {
.unwrap() .unwrap()
} }
/// Create a new user with the given details
///
/// Returns an Option containing the created user, or None if a user already exists with the same username
pub async fn create(username: &str, password: &str, role: UserRole) -> Option<Self> { pub async fn create(username: &str, password: &str, role: UserRole) -> Option<Self> {
// Check if a user already exists with the same username
if Self::find(username).await.is_some() { if Self::find(username).await.is_some() {
return None; return None;
} }
@ -60,6 +70,7 @@ impl User {
Some(u) Some(u)
} }
/// Login a user with the given username and password
pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> { pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> {
let u = Self::find(username).await?; let u = Self::find(username).await?;
@ -70,7 +81,9 @@ impl User {
Some((u.session().await, u.user_role)) Some((u.session().await, u.user_role))
} }
/// Change the password of a `User` /// Change the password of a User
///
/// Returns a Result indicating whether the password change was successful or not
pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> { pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> {
if self.verify_pw(old) { if self.verify_pw(old) {
sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;") sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;")
@ -86,6 +99,7 @@ impl User {
Err(()) Err(())
} }
/// Find all users in the system
pub async fn find_all() -> Vec<Self> { pub async fn find_all() -> Vec<Self> {
sqlx::query_as("SELECT * FROM users") sqlx::query_as("SELECT * FROM users")
.fetch_all(get_pg!()) .fetch_all(get_pg!())
@ -93,6 +107,9 @@ impl User {
.unwrap() .unwrap()
} }
/// Generate a new session token for the user
///
/// Returns a Session instance containing the generated token and associated user
pub async fn session(&self) -> Session { pub async fn session(&self) -> Session {
sqlx::query_as( sqlx::query_as(
"INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"", "INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"",
@ -104,10 +121,14 @@ impl User {
.unwrap() .unwrap()
} }
/// Check if the user is an admin
pub const fn is_admin(&self) -> bool { pub const fn is_admin(&self) -> bool {
matches!(self.user_role, UserRole::Admin) matches!(self.user_role, UserRole::Admin)
} }
/// Verify that a provided password matches the hashed password for the user
///
/// Returns a boolean indicating whether the passwords match or not
pub fn verify_pw(&self, password: &str) -> bool { pub fn verify_pw(&self, password: &str) -> bool {
bcrypt::verify(password, &self.password).unwrap() bcrypt::verify(password, &self.password).unwrap()
} }
@ -124,7 +145,10 @@ impl ToAPI for User {
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session { pub struct Session {
/// The unique ID of the session token
pub id: uuid::Uuid, pub id: uuid::Uuid,
/// The generated session token
pub token: String, pub token: String,
/// The username associated with the session token
pub user: String, pub user: String,
} }