docs
This commit is contained in:
parent
268ab4cc76
commit
334790da70
9 changed files with 366 additions and 37 deletions
|
@ -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<uuid::Uuid>,
|
||||
}
|
||||
|
||||
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 {
|
||||
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<Self> {
|
||||
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<uuid::Uuid>) -> Option<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Track> {
|
||||
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<String> {
|
||||
let track_path = Self::get_tracks_of_album(&self.id)
|
||||
.await
|
||||
|
|
|
@ -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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
|
|
|
@ -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<chrono::Utc>,
|
||||
|
||||
/// 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<Self> {
|
||||
sqlx::query_as("SELECT * FROM events WHERE \"user\" = $1 ORDER BY time DESC LIMIT 300")
|
||||
.bind(&u.username)
|
||||
|
|
|
@ -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<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())?
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the audio metadata for a given file path, or None if it fails.
|
||||
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)?))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let output = std::process::Command::new("python3")
|
||||
.arg("./extract_metadata.py")
|
||||
|
|
|
@ -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>,
|
||||
) -> 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<Artist> {
|
||||
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<Album> {
|
||||
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<Track> {
|
||||
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> {
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String>,
|
||||
|
||||
// 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<uuid::Uuid>,
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -33,29 +51,67 @@ impl Playlist {
|
|||
.bind(title)
|
||||
.bind(visibility)
|
||||
.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> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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<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) {
|
||||
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<Vec<SearchResult>> {
|
||||
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 {
|
||||
results.push(SearchResult {
|
||||
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 {
|
||||
results.push(SearchResult {
|
||||
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 {
|
||||
results.push(SearchResult {
|
||||
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 {
|
||||
results.push(SearchResult {
|
||||
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));
|
||||
|
||||
// Return results
|
||||
Some(results)
|
||||
}
|
||||
|
|
|
@ -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<chrono::Utc>,
|
||||
|
||||
/// Optional associated album
|
||||
pub album: Option<uuid::Uuid>,
|
||||
|
||||
/// Optional associated artist
|
||||
pub artist: Option<uuid::Uuid>,
|
||||
|
||||
/// Optional metadata for the track
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
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>) {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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> {
|
||||
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> {
|
||||
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<Self> {
|
||||
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<Track> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
sqlx::query_as("SELECT * FROM track WHERE album = $1 LIMIT 1")
|
||||
.bind(album)
|
||||
|
|
|
@ -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<Self> {
|
||||
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<Self> {
|
||||
// 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<Self> {
|
||||
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,
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue