Compare commits

...

3 Commits

Author SHA1 Message Date
Connor Johnstone
1f983bbecf Minimal subsonic functionality 2026-03-20 20:04:35 -04:00
Connor Johnstone
4fda9071c7 Added the playlist editor 2026-03-20 18:36:59 -04:00
Connor Johnstone
f03f8f0362 Added the playlist generator 2026-03-20 18:09:47 -04:00
11 changed files with 524 additions and 0 deletions

View File

@@ -1,6 +1,8 @@
pub mod album; pub mod album;
pub mod artist; pub mod artist;
pub mod download_queue; pub mod download_queue;
pub mod playlist;
pub mod playlist_track;
pub mod search_cache; pub mod search_cache;
pub mod track; pub mod track;
pub mod user; pub mod user;
@@ -9,6 +11,8 @@ pub mod wanted_item;
pub use album::Entity as Albums; pub use album::Entity as Albums;
pub use artist::Entity as Artists; pub use artist::Entity as Artists;
pub use download_queue::Entity as DownloadQueue; 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 search_cache::Entity as SearchCache;
pub use track::Entity as Tracks; pub use track::Entity as Tracks;
pub use user::Entity as Users; pub use user::Entity as Users;

43
src/entities/playlist.rs Normal file
View File

@@ -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<String>,
#[sea_orm(nullable)]
pub user_id: Option<i32>,
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<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::playlist_track::Entity> for Entity {
fn to() -> RelationDef {
Relation::PlaylistTracks.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<super::playlist::Entity> for Entity {
fn to() -> RelationDef {
Relation::Playlist.def()
}
}
impl Related<super::track::Entity> for Entity {
fn to() -> RelationDef {
Relation::Track.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -19,6 +19,9 @@ pub struct Model {
pub username: String, pub username: String,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub password_hash: String, pub password_hash: String,
#[serde(skip_serializing)]
#[sea_orm(nullable)]
pub subsonic_password: Option<String>,
pub role: UserRole, pub role: UserRole,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime,

View File

@@ -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,
}

View File

@@ -0,0 +1,35 @@
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
.alter_table(
Table::alter()
.table(Users::Table)
.add_column(ColumnDef::new(Users::SubsonicPassword).text())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Users::Table)
.drop_column(Users::SubsonicPassword)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Users {
Table,
SubsonicPassword,
}

View File

@@ -12,6 +12,8 @@ mod m20260317_000010_add_wanted_mbid;
mod m20260319_000011_create_users; mod m20260319_000011_create_users;
mod m20260319_000012_add_user_id_to_wanted_items; mod m20260319_000012_add_user_id_to_wanted_items;
mod m20260320_000013_add_artist_monitoring; mod m20260320_000013_add_artist_monitoring;
mod m20260320_000014_create_playlists;
mod m20260320_000015_add_subsonic_password;
pub struct Migrator; pub struct Migrator;
@@ -31,6 +33,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260319_000011_create_users::Migration), Box::new(m20260319_000011_create_users::Migration),
Box::new(m20260319_000012_add_user_id_to_wanted_items::Migration), Box::new(m20260319_000012_add_user_id_to_wanted_items::Migration),
Box::new(m20260320_000013_add_artist_monitoring::Migration), Box::new(m20260320_000013_add_artist_monitoring::Migration),
Box::new(m20260320_000014_create_playlists::Migration),
Box::new(m20260320_000015_add_subsonic_password::Migration),
] ]
} }
} }

View File

@@ -2,6 +2,7 @@ pub mod albums;
pub mod artists; pub mod artists;
pub mod cache; pub mod cache;
pub mod downloads; pub mod downloads;
pub mod playlists;
pub mod tracks; pub mod tracks;
pub mod users; pub mod users;
pub mod wanted; pub mod wanted;

178
src/queries/playlists.rs Normal file
View File

@@ -0,0 +1,178 @@
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<i32>,
track_ids: &[i32],
) -> DbResult<Playlist> {
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<i32>) -> DbResult<Vec<Playlist>> {
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<Playlist> {
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<Vec<Track>> {
// 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<u64> {
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<Playlist> {
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(())
}
pub async fn add_track(db: &DatabaseConnection, playlist_id: i32, track_id: i32) -> DbResult<()> {
// Get max position for this playlist
let max_pos = PlaylistTracks::find()
.filter(playlist_track::Column::PlaylistId.eq(playlist_id))
.order_by_desc(playlist_track::Column::Position)
.one(db)
.await?
.map(|pt| pt.position)
.unwrap_or(-1);
let pt = TrackActiveModel {
playlist_id: Set(playlist_id),
track_id: Set(track_id),
position: Set(max_pos + 1),
..Default::default()
};
pt.insert(db).await?;
Ok(())
}
pub async fn remove_track(
db: &DatabaseConnection,
playlist_id: i32,
track_id: i32,
) -> DbResult<()> {
// Delete the playlist_track row
PlaylistTracks::delete_many()
.filter(playlist_track::Column::PlaylistId.eq(playlist_id))
.filter(playlist_track::Column::TrackId.eq(track_id))
.exec(db)
.await?;
// Renumber remaining positions
let remaining = PlaylistTracks::find()
.filter(playlist_track::Column::PlaylistId.eq(playlist_id))
.order_by_asc(playlist_track::Column::Position)
.all(db)
.await?;
for (i, pt) in remaining.into_iter().enumerate() {
let mut active: TrackActiveModel = pt.into();
active.position = Set(i as i32);
active.update(db).await?;
}
Ok(())
}
pub async fn reorder_tracks(
db: &DatabaseConnection,
playlist_id: i32,
track_ids: Vec<i32>,
) -> DbResult<()> {
// Delete all playlist_tracks for this playlist
PlaylistTracks::delete_many()
.filter(playlist_track::Column::PlaylistId.eq(playlist_id))
.exec(db)
.await?;
// Re-insert with positions matching the vec order
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(())
}

View File

@@ -1,4 +1,5 @@
use chrono::Utc; use chrono::Utc;
use sea_orm::sea_query::Expr;
use sea_orm::*; use sea_orm::*;
use crate::entities::track::{self, ActiveModel, Entity as Tracks, Model as Track}; 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?; Tracks::delete_by_id(id).exec(db).await?;
Ok(()) Ok(())
} }
/// Get tracks matching a genre (case-insensitive).
pub async fn get_by_genre(db: &DatabaseConnection, genre: &str) -> DbResult<Vec<Track>> {
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<Vec<Track>> {
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<Vec<Track>> {
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<Vec<Track>> {
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<Vec<Track>> {
Ok(Tracks::find()
.filter(Expr::cust_with_values(
"LOWER(artist) = LOWER(?)",
[name.to_string()],
))
.all(db)
.await?)
}

View File

@@ -52,6 +52,19 @@ pub async fn count(db: &DatabaseConnection) -> DbResult<u64> {
Ok(Users::find().count(db).await?) Ok(Users::find().count(db).await?)
} }
/// Set the Subsonic password for a user. Stored as plaintext per the Subsonic protocol.
pub async fn set_subsonic_password(
db: &DatabaseConnection,
user_id: i32,
password: &str,
) -> DbResult<User> {
let existing = get_by_id(db, user_id).await?;
let mut active: ActiveModel = existing.into();
active.subsonic_password = Set(Some(password.to_string()));
active.updated_at = Set(Utc::now().naive_utc());
Ok(active.update(db).await?)
}
/// Assign all orphaned wanted items (user_id IS NULL) to the given user. /// Assign all orphaned wanted items (user_id IS NULL) to the given user.
pub async fn adopt_orphaned_wanted_items(db: &DatabaseConnection, user_id: i32) -> DbResult<u64> { pub async fn adopt_orphaned_wanted_items(db: &DatabaseConnection, user_id: i32) -> DbResult<u64> {
use crate::entities::wanted_item; use crate::entities::wanted_item;