From 334790da70b4712c77a4f1b5217fed209d8b165c Mon Sep 17 00:00:00 2001 From: JMARyA Date: Sun, 6 Oct 2024 01:12:26 +0200 Subject: [PATCH] docs --- src/library/album.rs | 16 ++++- src/library/artist.rs | 11 +++- src/library/event.rs | 23 ++++++- src/library/metadata.rs | 9 +++ src/library/mod.rs | 79 ++++++++++++++++++++--- src/library/playlist.rs | 68 ++++++++++++++++++-- src/library/search.rs | 37 +++++++---- src/library/track.rs | 134 ++++++++++++++++++++++++++++++++++++++-- src/library/user.rs | 26 +++++++- 9 files changed, 366 insertions(+), 37 deletions(-) diff --git a/src/library/album.rs b/src/library/album.rs index 97fbfd1..67d7629 100644 --- a/src/library/album.rs +++ b/src/library/album.rs @@ -9,12 +9,15 @@ use super::track::Track; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Album { + /// Unique identifier for the album in the database. pub id: uuid::Uuid, + /// Human-readable title of the album. 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, } - impl Album { + /// Creates a new album in the database with the given `title` and optional `artist`. pub async fn create(title: &str, artist: Option) -> Self { sqlx::query_as("INSERT INTO album (title, artist) VALUES ($1, $2) RETURNING *") .bind(title) @@ -24,6 +27,7 @@ impl Album { .unwrap() } + /// Retrieves a list of all albums in the database. pub async fn find_all() -> Vec { sqlx::query_as("SELECT * FROM album") .fetch_all(get_pg!()) @@ -31,6 +35,7 @@ impl Album { .unwrap() } + /// Finds an album in the database by its `title` and optional `artist`. pub async fn find(title: &str, artist: Option) -> Option { sqlx::query_as("SELECT * FROM album WHERE title = $1 AND artist = $2") .bind(title) @@ -40,6 +45,7 @@ impl Album { .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 { sqlx::query_as(&format!("SELECT * FROM album WHERE {col} ~* $1")) .bind(query) @@ -48,6 +54,7 @@ impl Album { .unwrap() } + /// Retrieves a list of albums from the database associated with the specified `artist`. pub async fn find_of_artist(artist: &uuid::Uuid) -> Vec { sqlx::query_as("SELECT * FROM album WHERE artist = $1") .bind(artist) @@ -56,6 +63,7 @@ impl Album { .unwrap() } + /// Deletes an album from the database. pub async fn remove(&self) { sqlx::query("DELETE FROM album WHERE id = $1") .bind(self.id) @@ -64,6 +72,7 @@ impl Album { .unwrap(); } + /// Retrieves an album from the database by its `id`. pub async fn get(id: &uuid::Uuid) -> Option { sqlx::query_as("SELECT * FROM album WHERE id = $1") .bind(id) @@ -72,6 +81,7 @@ impl Album { .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 { sqlx::query_as("SELECT * FROM track WHERE album = $1") .bind(album) @@ -80,9 +90,9 @@ impl Album { .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 { let track_path = Self::get_tracks_of_album(&self.id) .await diff --git a/src/library/artist.rs b/src/library/artist.rs index d8b3d72..3024466 100644 --- a/src/library/artist.rs +++ b/src/library/artist.rs @@ -9,11 +9,13 @@ use super::track::Track; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Artist { + /// Unique identifier for the artist pub id: uuid::Uuid, + /// Name of the artist pub name: String, } - impl Artist { + /// Creates a new artist with the given name. pub async fn create(name: &str) -> Self { sqlx::query_as("INSERT INTO artist (name) VALUES ($1) RETURNING id, name") .bind(name) @@ -22,6 +24,7 @@ impl Artist { .unwrap() } + /// Retrieves an artist by their unique identifier. pub async fn get(id: &uuid::Uuid) -> Option { sqlx::query_as("SELECT * FROM artist WHERE id = $1") .bind(id) @@ -30,6 +33,7 @@ impl Artist { .unwrap() } + /// Retrieves an artist by their name. pub async fn find(name: &str) -> Option { sqlx::query_as("SELECT * FROM artist WHERE name = $1") .bind(name) @@ -38,6 +42,7 @@ impl Artist { .unwrap() } + /// Retrieves all artists in the database. pub async fn find_all() -> Vec { sqlx::query_as("SELECT * FROM artist") .fetch_all(get_pg!()) @@ -45,6 +50,7 @@ impl Artist { .unwrap() } + /// Searches for artists with columns matching a given regular expression. pub async fn find_regex_col(col: &str, query: &str) -> Vec { sqlx::query_as(&format!("SELECT * FROM artist WHERE {col} ~* $1")) .bind(query) @@ -53,6 +59,7 @@ impl Artist { .unwrap() } + /// Deletes an artist. pub async fn remove(&self) { sqlx::query("DELETE FROM artist WHERE id = $1") .bind(self.id) @@ -61,7 +68,7 @@ impl Artist { .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. pub async fn get_image_of(id: &uuid::Uuid) -> Option { diff --git a/src/library/event.rs b/src/library/event.rs index f5e0b77..e7fc37d 100644 --- a/src/library/event.rs +++ b/src/library/event.rs @@ -2,25 +2,43 @@ use serde::{Deserialize, Serialize}; use sqlx::prelude::FromRow; use crate::{get_pg, library::user::User}; - +/// Represents a user event in the database. #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Event { + /// Unique identifier for this event. pub id: uuid::Uuid, + + /// Timestamp of when this event occurred. pub time: chrono::DateTime, + + /// Type of event (e.g. play, stop). pub kind: EventKind, + + /// Username of the user who performed this action. pub user: String, + + /// ID of the track associated with this event. pub track: uuid::Uuid, } +/// Enum representing different types of events that can occur. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "event_kind", rename_all = "lowercase")] pub enum EventKind { + /// A user started playing a track. Play, + + /// A user finished playing a track. Played, + + /// A user stopped playing a track. Stop, } 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 { sqlx::query_as("INSERT INTO events (kind, \"user\", track) VALUES ($1, $2, $3) RETURNING *") .bind(kind) @@ -31,6 +49,9 @@ impl Event { .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 { sqlx::query_as("SELECT * FROM events WHERE \"user\" = $1 ORDER BY time DESC LIMIT 300") .bind(&u.username) diff --git a/src/library/metadata.rs b/src/library/metadata.rs index 32746ff..7ec6893 100644 --- a/src/library/metadata.rs +++ b/src/library/metadata.rs @@ -6,31 +6,40 @@ use serde::{Deserialize, Serialize}; pub struct AudioMetadata(pub serde_json::Value); impl AudioMetadata { + /// Get a key from the JSON data if it exists. fn get_key(&self, key: &str) -> Option<&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> { self.get_key("title") } + /// Get the artist of the audio metadata if it exists. pub fn artist(&self) -> Option<&str> { self.get_key("artist") } + /// Get the album of the audio metadata if it exists. pub fn album(&self) -> Option<&str> { self.get_key("album") } + /// Get the track number of the audio metadata if it exists. pub fn track_number(&self) -> Option { + // Note: This will return an error if the track number is not a valid integer 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 { + // Simply wrap the JSON data in an `AudioMetadata` instance 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 { let output = std::process::Command::new("python3") .arg("./extract_metadata.py") diff --git a/src/library/mod.rs b/src/library/mod.rs index 5d8907e..8969257 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -34,41 +34,59 @@ fn is_music_file(path: &Path) -> bool { pub struct Libary { root_dir: PathBuf, } - impl Libary { + /// Creates a new instance of the Libary struct with the specified `root_dir`. pub const fn new(root_dir: PathBuf) -> Self { 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 { if let Some(artist) = Artist::find(artist).await { + // Return the existing artist's ID. artist.id } else { + // Create a new artist record with the given name and return its 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( &self, album: &str, artist_id: Option, ) -> uuid::Uuid { if let Some(album) = Album::find(album, artist_id).await { + // Return the existing album's ID. album.id } else { + // Create a new album record with the given title and return its 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) { - // search for path already present + // Check if the path is already present in the library records. if Track::of_path(path).await.is_some() { return; } 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 mut entry = json!({ @@ -76,6 +94,7 @@ impl Libary { }); 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() { let artist_id = self.find_or_create_artist(artist).await; entry @@ -86,6 +105,7 @@ impl Libary { 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() { let album_id = self .find_or_create_album( @@ -106,6 +126,7 @@ impl Libary { 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() { entry .as_object_mut() @@ -114,6 +135,7 @@ impl Libary { } } + // Add any available metadata to the track record. if let Some(metadata) = metadata { entry .as_object_mut() @@ -121,7 +143,7 @@ impl Libary { .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() { entry.as_object_mut().unwrap().insert( "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; } + /// Retrieves all artists from the database. pub async fn get_artists(&self) -> Vec { sqlx::query_as("SELECT * FROM artist") .fetch_all(get_pg!()) @@ -144,6 +168,7 @@ impl Libary { .unwrap() } + /// Retrieves all albums for a given artist ID. pub async fn get_albums_by_artist(&self, artist: &uuid::Uuid) -> Vec { sqlx::query_as("SELECT * FROM album WHERE artist = $1") .bind(artist) @@ -152,6 +177,7 @@ impl Libary { .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 { sqlx::query_as("SELECT * FROM track WHERE album IS NULL AND artist = $1") .bind(artist) @@ -160,26 +186,43 @@ impl Libary { .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::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::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<(), ()> { + // Retrieve the track record from the database. 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; + log::info!("Rescanning metadata for {path}"); + // Retrieve the current metadata for the track. let metadata = metadata::get_metadata(path); + // Create an update object to store changes to the metadata. let mut update = json!({}); + // If metadata is available, extract artist and album information. if let Some(meta) = &metadata { + // Get the artist associated with the track (if present). 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; + + // Add the artist ID to the update object. update .as_object_mut() .unwrap() @@ -188,7 +231,10 @@ impl Libary { log::warn!("{path} has no artist"); } + // Get the album associated with the track (if present). 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 .find_or_create_album( album, @@ -199,6 +245,8 @@ impl Libary { .map(|x| uuid::Uuid::from_str(x.as_str().unwrap()).unwrap()), ) .await; + + // Add the album ID to the update object. update .as_object_mut() .unwrap() @@ -207,6 +255,7 @@ impl Libary { log::warn!("{path} has no album and will be treated as single"); } + // Get the track title (if present). if let Some(title) = meta.title() { update .as_object_mut() @@ -215,6 +264,7 @@ impl Libary { } } + // Add any remaining metadata to the update object. if let Some(metadata) = metadata { update .as_object_mut() @@ -222,7 +272,7 @@ impl Libary { .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() { update.as_object_mut().unwrap().insert( "title".into(), @@ -235,27 +285,34 @@ impl Libary { ); } + // Apply the updated metadata to the track record. track.update(&update).await.unwrap(); 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) { - // tracks + // Clean up lost track records. for track in Track::find_all().await { if !std::path::Path::new(&track.path).exists() { log::info!("Cleaning lost {}", track.path); track.remove().await; } } - // albums + + // Clean up lost album records. for album in Album::find_all().await { if Track::find_of_album(&album.id).await.is_empty() { log::info!("Cleaning album {} with no tracks", album.title); album.remove().await; } } - // artists + + // Clean up lost artist records. for artist in Artist::find_all().await { if Track::find_first_of_artist(&artist.id).await.is_none() && 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) { cache.invalidate("albums", "latest").await; log::info!("Rescanning library"); + for entry in WalkDir::new(self.root_dir.clone()) .follow_links(true) .into_iter() .filter_map(std::result::Result::ok) { let path = entry.path(); + if path.is_file() && is_music_file(path) { let path = path.to_string_lossy().to_string(); + self.add_path_to_library(&path).await; } } diff --git a/src/library/playlist.rs b/src/library/playlist.rs index f34cbca..468c26c 100644 --- a/src/library/playlist.rs +++ b/src/library/playlist.rs @@ -5,12 +5,23 @@ use crate::{get_pg, library::user::User}; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Playlist { + // Unique identifier of the playlist pub id: uuid::Uuid, + + // Optional string representation of the playlist's ID #[sqlx(default)] pub id_str: Option, + + // Owner of the playlist pub owner: String, + + // Human-readable title of the playlist pub title: String, + + // Level of visibility for this playlist (public, private, etc.) pub visibility: Visibility, + + // IDs of tracks that belong to this playlist pub tracks: Vec, } @@ -20,8 +31,15 @@ pub enum Visibility { Private, Public, } - 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( owner: User, title: &str, @@ -29,33 +47,71 @@ impl Playlist { tracks: &[uuid::Uuid], ) -> Option { sqlx::query_as("INSERT INTO playlist (owner, title, visibility, tracks) VALUES ($1, $2, $3, $4) RETURNING *") - .bind(owner.username) - .bind(title) - .bind(visibility) - .bind(tracks) - .fetch_one(get_pg!()).await.ok() + .bind(owner.username) + .bind(title) + .bind(visibility) + .bind(tracks) + .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 { sqlx::query_as(&format!("SELECT * FROM playlist WHERE {col} ~* $1")) + // Bind the regular expression pattern as a string .bind(query) + // Execute the query using the PostgreSQL connection pool and fetch all results .fetch_all(get_pg!()) .await + // Return the result as a list of `Playlist` instances (unwrap() is used here, but consider adding error handling) .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 { sqlx::query_as("SELECT * FROM playlist WHERE id = $1") + // Bind the UUID of the playlist to retrieve .bind(id) + // Execute the query using the PostgreSQL connection pool and fetch an optional result .fetch_optional(get_pg!()) + // Return the result as an optional `Playlist` instance (unwrap() is used here, but consider adding error handling) .await .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 { sqlx::query_as("SELECT * FROM playlist WHERE owner = $1") + // Bind the username of the user who owns the playlists .bind(&u.username) + // Execute the query using the PostgreSQL connection pool and fetch all results .fetch_all(get_pg!()) + // Return the result as a list of `Playlist` instances (unwrap() is used here, but consider adding error handling) .await .unwrap() } diff --git a/src/library/search.rs b/src/library/search.rs index 75cb67f..36a0b2b 100644 --- a/src/library/search.rs +++ b/src/library/search.rs @@ -5,33 +5,39 @@ use crate::route::ToAPI; 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) -> 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) { 10.0 } else { 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); - // 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 { recency_factor(date) } 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 } +// Calculate the similarity between two strings using character matching and length penalty fn string_similarity(field: &str, search_term: &str) -> f64 { + // Initialize a counter for matching characters let mut match_count = 0; + + // Convert both strings to lowercase for case-insensitive comparison let field_lower = field.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() { if let Some(field_char) = field_lower.chars().nth(i) { 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; + + // 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; + // Return the similarity score, capping it at 0 to prevent negative scores (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 { + // Get the current 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_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 { 5.0 // Boost recent items } else if age_in_days < 365.0 { @@ -67,10 +82,11 @@ pub struct SearchResult { score: f64, } +// Perform a search across artists, albums, tracks, and playlists based on the query term pub async fn search_for(query: String) -> Option> { let mut results: Vec = Vec::new(); - // Add artist results + // Search for artists matching the query term for artist in Artist::find_regex_col("name", &query).await { results.push(SearchResult { kind: "artist".to_string(), @@ -79,7 +95,7 @@ pub async fn search_for(query: String) -> Option> { }); } - // Add album results + // Search for albums matching the query term for album in Album::find_regex_col("title", &query).await { results.push(SearchResult { kind: "album".to_string(), @@ -88,7 +104,7 @@ pub async fn search_for(query: String) -> Option> { }); } - // Add track results + // Search for tracks matching the query term for track in Track::find_regex_col("title", &query).await { results.push(SearchResult { kind: "track".to_string(), @@ -97,7 +113,7 @@ pub async fn search_for(query: String) -> Option> { }); } - // Add playlist results + // Search for playlists matching the query term for playlist in Playlist::find_regex_col("title", &query).await { results.push(SearchResult { kind: "playlist".to_string(), @@ -106,9 +122,8 @@ pub async fn search_for(query: String) -> Option> { }); } - // 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)); - // Return results Some(results) } diff --git a/src/library/track.rs b/src/library/track.rs index 458c334..867ac85 100644 --- a/src/library/track.rs +++ b/src/library/track.rs @@ -13,16 +13,34 @@ use super::{event::Event, metadata::AudioMetadata, user::User}; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Track { + /// The unique identifier of the track pub id: uuid::Uuid, + + // The file path where the track is stored pub path: String, + + /// The title of the track pub title: String, + + /// The date when the track was added to the library pub date_added: chrono::DateTime, + + /// Optional associated album pub album: Option, + + /// Optional associated artist pub artist: Option, + + /// Optional metadata for the track pub meta: Option, } impl Track { + /// Create a new track. + /// + /// # Arguments + /// + /// * `data` - The data to create the track with. pub async fn create(data: &serde_json::Map) { sqlx::query( "INSERT INTO track (path, title, meta, album, artist) VALUES ($1, $2, $3, $4, $5)", @@ -43,6 +61,15 @@ impl Track { .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 { sqlx::query_as("SELECT * FROM track WHERE path = $1") .bind(path) @@ -51,6 +78,11 @@ impl Track { .unwrap() } + /// Find all recently added tracks. + /// + /// # Returns + /// + /// A vector of recently added tracks. pub async fn find_recently_added() -> Vec { sqlx::query_as("SELECT * FROM track ORDER BY date_added DESC LIMIT 90") .fetch_all(get_pg!()) @@ -58,6 +90,15 @@ impl Track { .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 { sqlx::query_as("SELECT * FROM track WHERE id = $1") .bind(id) @@ -66,6 +107,15 @@ impl Track { .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<()> { let map = update_set.as_object()?; let artist = map @@ -93,6 +143,11 @@ impl Track { Some(()) } + /// Find all tracks. + /// + /// # Returns + /// + /// A vector of all tracks. pub async fn find_all() -> Vec { sqlx::query_as("SELECT * FROM track") .fetch_all(get_pg!()) @@ -100,6 +155,11 @@ impl Track { .unwrap() } + /// Remove a track. + /// + /// # Arguments + /// + /// * `id` - The ID of the track to remove. pub async fn remove(&self) { sqlx::query("DELETE FROM track WHERE id = $1") .bind(self.id) @@ -108,6 +168,15 @@ impl Track { .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 { sqlx::query_as("SELECT * FROM track WHERE album = $1") .bind(album) @@ -116,6 +185,18 @@ impl Track { .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 { let latest_events = Event::get_latest_events_of(u).await; let mut ids = HashSet::new(); @@ -133,17 +214,46 @@ impl Track { .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 { 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 { 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 { let transcoded = format!("./data/transcode/{codec}/{bitrate}/{}.{ext}", self.id); @@ -173,6 +283,16 @@ impl Track { 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 { sqlx::query_as(&format!("SELECT * FROM track WHERE {col} ~* $1")) .bind(query) @@ -181,7 +301,11 @@ impl Track { .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 { sqlx::query_as("SELECT * FROM track WHERE artist IS NULL AND album IS NULL") .fetch_all(get_pg!()) @@ -189,6 +313,7 @@ impl Track { .unwrap() } + /// Finds the first track associated with a given artist. pub async fn find_first_of_artist(artist: &uuid::Uuid) -> Option { sqlx::query_as("SELECT * FROM track WHERE artist = $1 LIMIT 1") .bind(artist) @@ -197,6 +322,7 @@ impl Track { .unwrap() } + /// Finds the first track of a given album. pub async fn find_first_of_album(album: &uuid::Uuid) -> Option { sqlx::query_as("SELECT * FROM track WHERE album = $1 LIMIT 1") .bind(album) diff --git a/src/library/user.rs b/src/library/user.rs index 87b43b8..4931c16 100644 --- a/src/library/user.rs +++ b/src/library/user.rs @@ -17,19 +17,25 @@ fn gen_token(token_length: usize) -> String { #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct User { + /// The username chosen by the user pub username: String, + /// The hashed password for the user pub password: String, + /// The role of the user pub user_role: UserRole, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "user_role", rename_all = "lowercase")] pub enum UserRole { + /// A regular user with limited permissions Regular, + /// An admin user with full system privileges Admin, } impl User { + /// Find a user by their username pub async fn find(username: &str) -> Option { sqlx::query_as("SELECT * FROM users WHERE username = $1") .bind(username) @@ -38,7 +44,11 @@ impl User { .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 { + // Check if a user already exists with the same username if Self::find(username).await.is_some() { return None; } @@ -60,6 +70,7 @@ impl User { Some(u) } + /// Login a user with the given username and password pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> { let u = Self::find(username).await?; @@ -70,7 +81,9 @@ impl User { 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<(), ()> { if self.verify_pw(old) { sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;") @@ -86,6 +99,7 @@ impl User { Err(()) } + /// Find all users in the system pub async fn find_all() -> Vec { sqlx::query_as("SELECT * FROM users") .fetch_all(get_pg!()) @@ -93,6 +107,9 @@ impl User { .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 { sqlx::query_as( "INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"", @@ -104,10 +121,14 @@ impl User { .unwrap() } + /// Check if the user is an admin pub const fn is_admin(&self) -> bool { 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 { bcrypt::verify(password, &self.password).unwrap() } @@ -124,7 +145,10 @@ impl ToAPI for User { #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Session { + /// The unique ID of the session token pub id: uuid::Uuid, + /// The generated session token pub token: String, + /// The username associated with the session token pub user: String, }