From f03f8f0362d84a45ea1f6cd75d5d39f88f8897fb Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Fri, 20 Mar 2026 18:09:47 -0400 Subject: [PATCH] Added the playlist generator --- src/entities/mod.rs | 4 + src/entities/playlist.rs | 43 ++++++ src/entities/playlist_track.rs | 43 ++++++ .../m20260320_000014_create_playlists.rs | 136 ++++++++++++++++++ src/migration/mod.rs | 2 + src/queries/mod.rs | 1 + src/queries/playlists.rs | 105 ++++++++++++++ src/queries/tracks.rs | 64 +++++++++ 8 files changed, 398 insertions(+) create mode 100644 src/entities/playlist.rs create mode 100644 src/entities/playlist_track.rs create mode 100644 src/migration/m20260320_000014_create_playlists.rs create mode 100644 src/queries/playlists.rs diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 707fb9d..1cf652e 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,6 +1,8 @@ pub mod album; pub mod artist; pub mod download_queue; +pub mod playlist; +pub mod playlist_track; pub mod search_cache; pub mod track; pub mod user; @@ -9,6 +11,8 @@ pub mod wanted_item; pub use album::Entity as Albums; pub use artist::Entity as Artists; pub use download_queue::Entity as DownloadQueue; +pub use playlist::Entity as Playlists; +pub use playlist_track::Entity as PlaylistTracks; pub use search_cache::Entity as SearchCache; pub use track::Entity as Tracks; pub use user::Entity as Users; diff --git a/src/entities/playlist.rs b/src/entities/playlist.rs new file mode 100644 index 0000000..da0f528 --- /dev/null +++ b/src/entities/playlist.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "playlists")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(nullable)] + pub description: Option, + #[sea_orm(nullable)] + pub user_id: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "SetNull" + )] + User, + #[sea_orm(has_many = "super::playlist_track::Entity")] + PlaylistTracks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlaylistTracks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/playlist_track.rs b/src/entities/playlist_track.rs new file mode 100644 index 0000000..df35a65 --- /dev/null +++ b/src/entities/playlist_track.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "playlist_tracks")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub playlist_id: i32, + pub track_id: i32, + pub position: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::playlist::Entity", + from = "Column::PlaylistId", + to = "super::playlist::Column::Id", + on_delete = "Cascade" + )] + Playlist, + #[sea_orm( + belongs_to = "super::track::Entity", + from = "Column::TrackId", + to = "super::track::Column::Id" + )] + Track, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Playlist.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Track.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/migration/m20260320_000014_create_playlists.rs b/src/migration/m20260320_000014_create_playlists.rs new file mode 100644 index 0000000..5ecbc9f --- /dev/null +++ b/src/migration/m20260320_000014_create_playlists.rs @@ -0,0 +1,136 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Playlists::Table) + .if_not_exists() + .col( + ColumnDef::new(Playlists::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Playlists::Name).text().not_null()) + .col(ColumnDef::new(Playlists::Description).text()) + .col(ColumnDef::new(Playlists::UserId).integer()) + .col( + ColumnDef::new(Playlists::CreatedAt) + .date_time() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Playlists::UpdatedAt) + .date_time() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .from(Playlists::Table, Playlists::UserId) + .to(Users::Table, Users::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(PlaylistTracks::Table) + .if_not_exists() + .col( + ColumnDef::new(PlaylistTracks::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(PlaylistTracks::PlaylistId) + .integer() + .not_null(), + ) + .col(ColumnDef::new(PlaylistTracks::TrackId).integer().not_null()) + .col( + ColumnDef::new(PlaylistTracks::Position) + .integer() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from(PlaylistTracks::Table, PlaylistTracks::PlaylistId) + .to(Playlists::Table, Playlists::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(PlaylistTracks::Table, PlaylistTracks::TrackId) + .to(Tracks::Table, Tracks::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_playlist_tracks_unique") + .table(PlaylistTracks::Table) + .col(PlaylistTracks::PlaylistId) + .col(PlaylistTracks::Position) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(PlaylistTracks::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Playlists::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Playlists { + Table, + Id, + Name, + Description, + UserId, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum PlaylistTracks { + Table, + Id, + PlaylistId, + TrackId, + Position, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Tracks { + Table, + Id, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index 67d3f33..2981bfd 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -12,6 +12,7 @@ mod m20260317_000010_add_wanted_mbid; mod m20260319_000011_create_users; mod m20260319_000012_add_user_id_to_wanted_items; mod m20260320_000013_add_artist_monitoring; +mod m20260320_000014_create_playlists; pub struct Migrator; @@ -31,6 +32,7 @@ impl MigratorTrait for Migrator { Box::new(m20260319_000011_create_users::Migration), Box::new(m20260319_000012_add_user_id_to_wanted_items::Migration), Box::new(m20260320_000013_add_artist_monitoring::Migration), + Box::new(m20260320_000014_create_playlists::Migration), ] } } diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 303c233..6a806cd 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -2,6 +2,7 @@ pub mod albums; pub mod artists; pub mod cache; pub mod downloads; +pub mod playlists; pub mod tracks; pub mod users; pub mod wanted; diff --git a/src/queries/playlists.rs b/src/queries/playlists.rs new file mode 100644 index 0000000..4f58b15 --- /dev/null +++ b/src/queries/playlists.rs @@ -0,0 +1,105 @@ +use chrono::Utc; +use sea_orm::*; + +use crate::entities::playlist::{self, ActiveModel, Entity as Playlists, Model as Playlist}; +use crate::entities::playlist_track::{ + self, ActiveModel as TrackActiveModel, Entity as PlaylistTracks, +}; +use crate::entities::track::{Entity as Tracks, Model as Track}; +use crate::error::{DbError, DbResult}; + +pub async fn create( + db: &DatabaseConnection, + name: &str, + description: Option<&str>, + user_id: Option, + track_ids: &[i32], +) -> DbResult { + let now = Utc::now().naive_utc(); + let active = ActiveModel { + name: Set(name.to_string()), + description: Set(description.map(String::from)), + user_id: Set(user_id), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + let playlist = active.insert(db).await?; + + // Insert tracks with positions + for (pos, track_id) in track_ids.iter().enumerate() { + let pt = TrackActiveModel { + playlist_id: Set(playlist.id), + track_id: Set(*track_id), + position: Set(pos as i32), + ..Default::default() + }; + pt.insert(db).await?; + } + + Ok(playlist) +} + +pub async fn list(db: &DatabaseConnection, user_id: Option) -> DbResult> { + let mut query = Playlists::find().order_by_desc(playlist::Column::CreatedAt); + if let Some(uid) = user_id { + query = query.filter(playlist::Column::UserId.eq(uid)); + } + Ok(query.all(db).await?) +} + +pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult { + Playlists::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| DbError::NotFound(format!("playlist id={id}"))) +} + +pub async fn get_tracks(db: &DatabaseConnection, playlist_id: i32) -> DbResult> { + // Get playlist_tracks ordered by position, then join with tracks + let pt_list = PlaylistTracks::find() + .filter(playlist_track::Column::PlaylistId.eq(playlist_id)) + .order_by_asc(playlist_track::Column::Position) + .all(db) + .await?; + + let mut tracks = Vec::with_capacity(pt_list.len()); + for pt in pt_list { + if let Some(track) = Tracks::find_by_id(pt.track_id).one(db).await? { + tracks.push(track); + } + } + Ok(tracks) +} + +pub async fn get_track_count(db: &DatabaseConnection, playlist_id: i32) -> DbResult { + let count = PlaylistTracks::find() + .filter(playlist_track::Column::PlaylistId.eq(playlist_id)) + .count(db) + .await?; + Ok(count) +} + +pub async fn update( + db: &DatabaseConnection, + id: i32, + name: Option<&str>, + description: Option<&str>, +) -> DbResult { + let existing = get_by_id(db, id).await?; + let mut active: ActiveModel = existing.into(); + if let Some(n) = name { + active.name = Set(n.to_string()); + } + if let Some(d) = description { + active.description = Set(Some(d.to_string())); + } + active.updated_at = Set(Utc::now().naive_utc()); + Ok(active.update(db).await?) +} + +pub async fn delete(db: &DatabaseConnection, id: i32) -> DbResult<()> { + // playlist_tracks cascade-deleted by FK + Playlists::delete_by_id(id).exec(db).await?; + Ok(()) +} diff --git a/src/queries/tracks.rs b/src/queries/tracks.rs index 074b175..bc5e7da 100644 --- a/src/queries/tracks.rs +++ b/src/queries/tracks.rs @@ -1,4 +1,5 @@ use chrono::Utc; +use sea_orm::sea_query::Expr; use sea_orm::*; use crate::entities::track::{self, ActiveModel, Entity as Tracks, Model as Track}; @@ -121,3 +122,66 @@ pub async fn delete(db: &DatabaseConnection, id: i32) -> DbResult<()> { Tracks::delete_by_id(id).exec(db).await?; Ok(()) } + +/// Get tracks matching a genre (case-insensitive). +pub async fn get_by_genre(db: &DatabaseConnection, genre: &str) -> DbResult> { + let pattern = format!("%{genre}%"); + Ok(Tracks::find() + .filter(Expr::cust_with_values( + "LOWER(genre) LIKE LOWER(?)", + [pattern], + )) + .all(db) + .await?) +} + +/// Get tracks by artist MusicBrainz ID (via the artist table). +pub async fn get_by_artist_mbid(db: &DatabaseConnection, mbid: &str) -> DbResult> { + use crate::entities::artist; + + // Find artist with this MBID + let artist = artist::Entity::find() + .filter(artist::Column::MusicbrainzId.eq(mbid)) + .one(db) + .await?; + + if let Some(a) = artist { + Ok(Tracks::find() + .filter(track::Column::ArtistId.eq(a.id)) + .all(db) + .await?) + } else { + Ok(vec![]) + } +} + +/// Get random tracks. +pub async fn get_random(db: &DatabaseConnection, count: u64) -> DbResult> { + Ok(Tracks::find() + .order_by(Expr::cust("RANDOM()"), Order::Asc) + .limit(count) + .all(db) + .await?) +} + +/// Get tracks added within the last N days. +pub async fn get_recent(db: &DatabaseConnection, days: u32, limit: u64) -> DbResult> { + let cutoff = Utc::now().naive_utc() - chrono::Duration::days(i64::from(days)); + Ok(Tracks::find() + .filter(track::Column::AddedAt.gte(cutoff)) + .order_by_desc(track::Column::AddedAt) + .limit(limit) + .all(db) + .await?) +} + +/// Get tracks by artist name (case-insensitive match). +pub async fn get_by_artist_name(db: &DatabaseConnection, name: &str) -> DbResult> { + Ok(Tracks::find() + .filter(Expr::cust_with_values( + "LOWER(artist) = LOWER(?)", + [name.to_string()], + )) + .all(db) + .await?) +}