Compare commits
13 Commits
305ddff278
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f983bbecf | ||
|
|
4fda9071c7 | ||
|
|
f03f8f0362 | ||
|
|
8a1435d9e9 | ||
|
|
9917ee981d | ||
|
|
a9d414bffa | ||
|
|
c6452609d6 | ||
|
|
37410ce216 | ||
|
|
dae5fce12c | ||
|
|
1fbafc25df | ||
|
|
9e1886b45a | ||
|
|
e1b682b048 | ||
|
|
2997d7e4f9 |
@@ -14,6 +14,9 @@ pub struct Model {
|
|||||||
pub top_songs: String,
|
pub top_songs: String,
|
||||||
/// JSON-serialized Vec of similar artist info
|
/// JSON-serialized Vec of similar artist info
|
||||||
pub similar_artists: String,
|
pub similar_artists: String,
|
||||||
|
pub monitored: bool,
|
||||||
|
#[sea_orm(nullable)]
|
||||||
|
pub last_checked_at: Option<chrono::NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
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 wanted_item;
|
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 wanted_item::Entity as WantedItems;
|
pub use wanted_item::Entity as WantedItems;
|
||||||
|
|||||||
43
src/entities/playlist.rs
Normal file
43
src/entities/playlist.rs
Normal 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 {}
|
||||||
43
src/entities/playlist_track.rs
Normal file
43
src/entities/playlist_track.rs
Normal 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 {}
|
||||||
42
src/entities/user.rs
Normal file
42
src/entities/user.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)]
|
||||||
|
#[sea_orm(rs_type = "String", db_type = "Text")]
|
||||||
|
pub enum UserRole {
|
||||||
|
#[sea_orm(string_value = "admin")]
|
||||||
|
Admin,
|
||||||
|
#[sea_orm(string_value = "user")]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
#[sea_orm(nullable)]
|
||||||
|
pub subsonic_password: Option<String>,
|
||||||
|
pub role: UserRole,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::wanted_item::Entity")]
|
||||||
|
WantedItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::wanted_item::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::WantedItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -10,6 +10,8 @@ pub enum WantedStatus {
|
|||||||
Available,
|
Available,
|
||||||
#[sea_orm(string_value = "downloaded")]
|
#[sea_orm(string_value = "downloaded")]
|
||||||
Downloaded,
|
Downloaded,
|
||||||
|
#[sea_orm(string_value = "owned")]
|
||||||
|
Owned,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)]
|
||||||
@@ -35,6 +37,11 @@ pub struct Model {
|
|||||||
pub album_id: Option<i32>,
|
pub album_id: Option<i32>,
|
||||||
#[sea_orm(nullable)]
|
#[sea_orm(nullable)]
|
||||||
pub track_id: Option<i32>,
|
pub track_id: Option<i32>,
|
||||||
|
#[sea_orm(nullable)]
|
||||||
|
pub user_id: Option<i32>,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(nullable)]
|
||||||
|
pub musicbrainz_id: Option<String>,
|
||||||
pub status: WantedStatus,
|
pub status: WantedStatus,
|
||||||
pub added_at: chrono::NaiveDateTime,
|
pub added_at: chrono::NaiveDateTime,
|
||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
@@ -42,6 +49,13 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::artist::Entity",
|
belongs_to = "super::artist::Entity",
|
||||||
from = "Column::ArtistId",
|
from = "Column::ArtistId",
|
||||||
@@ -67,6 +81,12 @@ pub enum Relation {
|
|||||||
Downloads,
|
Downloads,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Related<super::artist::Entity> for Entity {
|
impl Related<super::artist::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::Artist.def()
|
Relation::Artist.def()
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub enum DbError {
|
|||||||
|
|
||||||
#[error("serialization error: {0}")]
|
#[error("serialization error: {0}")]
|
||||||
Serialization(#[from] serde_json::Error),
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type DbResult<T> = Result<T, DbError>;
|
pub type DbResult<T> = Result<T, DbError>;
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ impl Database {
|
|||||||
.min_connections(1)
|
.min_connections(1)
|
||||||
.sqlx_logging(false);
|
.sqlx_logging(false);
|
||||||
|
|
||||||
let conn = SeaDatabase::connect(opts)
|
let conn = SeaDatabase::connect(opts).await.map_err(DbError::SeaOrm)?;
|
||||||
.await
|
|
||||||
.map_err(DbError::SeaOrm)?;
|
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent read performance
|
// Enable WAL mode for better concurrent read performance
|
||||||
if database_url.starts_with("sqlite:") && !database_url.contains(":memory:") {
|
if database_url.starts_with("sqlite:") && !database_url.contains(":memory:") {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ impl MigrationTrait for Migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
|
#[allow(clippy::enum_variant_names)]
|
||||||
pub(crate) enum Artists {
|
pub(crate) enum Artists {
|
||||||
Table,
|
Table,
|
||||||
Id,
|
Id,
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ impl MigrationTrait for Migration {
|
|||||||
.auto_increment()
|
.auto_increment()
|
||||||
.primary_key(),
|
.primary_key(),
|
||||||
)
|
)
|
||||||
.col(ColumnDef::new(Tracks::FilePath).text().not_null().unique_key())
|
.col(
|
||||||
|
ColumnDef::new(Tracks::FilePath)
|
||||||
|
.text()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
.col(ColumnDef::new(Tracks::Title).text())
|
.col(ColumnDef::new(Tracks::Title).text())
|
||||||
.col(ColumnDef::new(Tracks::Artist).text())
|
.col(ColumnDef::new(Tracks::Artist).text())
|
||||||
.col(ColumnDef::new(Tracks::Album).text())
|
.col(ColumnDef::new(Tracks::Album).text())
|
||||||
|
|||||||
101
src/migration/m20260317_000007_unique_artist_album.rs
Normal file
101
src/migration/m20260317_000007_unique_artist_album.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
use super::m20260317_000001_create_artists::Artists;
|
||||||
|
use super::m20260317_000002_create_albums::Albums;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Drop the old non-unique index on artists.name
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_artists_name")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create a unique index on artists.name
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_artists_name_unique")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.col(Artists::Name)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Drop the old non-unique index on albums.name
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_albums_name")
|
||||||
|
.table(Albums::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create a unique composite index on albums(name, album_artist)
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_albums_name_artist_unique")
|
||||||
|
.table(Albums::Table)
|
||||||
|
.col(Albums::Name)
|
||||||
|
.col(Albums::AlbumArtist)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_albums_name_artist_unique")
|
||||||
|
.table(Albums::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_albums_name")
|
||||||
|
.table(Albums::Table)
|
||||||
|
.col(Albums::Name)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_artists_name_unique")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_artists_name")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.col(Artists::Name)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/migration/m20260317_000009_add_wanted_name.rs
Normal file
40
src/migration/m20260317_000009_add_wanted_name.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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(WantedItems::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(WantedItems::Name)
|
||||||
|
.text()
|
||||||
|
.not_null()
|
||||||
|
.default(""),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(WantedItems::Table)
|
||||||
|
.drop_column(WantedItems::Name)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum WantedItems {
|
||||||
|
Table,
|
||||||
|
Name,
|
||||||
|
}
|
||||||
35
src/migration/m20260317_000010_add_wanted_mbid.rs
Normal file
35
src/migration/m20260317_000010_add_wanted_mbid.rs
Normal 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(WantedItems::Table)
|
||||||
|
.add_column(ColumnDef::new(WantedItems::MusicbrainzId).text())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(WantedItems::Table)
|
||||||
|
.drop_column(WantedItems::MusicbrainzId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum WantedItems {
|
||||||
|
Table,
|
||||||
|
MusicbrainzId,
|
||||||
|
}
|
||||||
67
src/migration/m20260319_000011_create_users.rs
Normal file
67
src/migration/m20260319_000011_create_users.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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(Users::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::Username)
|
||||||
|
.text()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Users::PasswordHash).text().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::Role)
|
||||||
|
.text()
|
||||||
|
.not_null()
|
||||||
|
.default("user"),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::CreatedAt)
|
||||||
|
.date_time()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::UpdatedAt)
|
||||||
|
.date_time()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Users::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
pub(crate) enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Username,
|
||||||
|
PasswordHash,
|
||||||
|
Role,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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(WantedItems::Table)
|
||||||
|
.add_column(ColumnDef::new(WantedItems::UserId).integer())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_wanted_items_user_id")
|
||||||
|
.table(WantedItems::Table)
|
||||||
|
.col(WantedItems::UserId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(WantedItems::Table)
|
||||||
|
.drop_column(WantedItems::UserId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum WantedItems {
|
||||||
|
Table,
|
||||||
|
UserId,
|
||||||
|
}
|
||||||
59
src/migration/m20260320_000013_add_artist_monitoring.rs
Normal file
59
src/migration/m20260320_000013_add_artist_monitoring.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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(Artists::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Artists::Monitored)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Artists::Table)
|
||||||
|
.add_column(ColumnDef::new(Artists::LastCheckedAt).timestamp())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Artists::Table)
|
||||||
|
.drop_column(Artists::LastCheckedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Artists::Table)
|
||||||
|
.drop_column(Artists::Monitored)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Artists {
|
||||||
|
Table,
|
||||||
|
Monitored,
|
||||||
|
LastCheckedAt,
|
||||||
|
}
|
||||||
136
src/migration/m20260320_000014_create_playlists.rs
Normal file
136
src/migration/m20260320_000014_create_playlists.rs
Normal 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,
|
||||||
|
}
|
||||||
35
src/migration/m20260320_000015_add_subsonic_password.rs
Normal file
35
src/migration/m20260320_000015_add_subsonic_password.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ mod m20260317_000003_create_tracks;
|
|||||||
mod m20260317_000004_create_wanted_items;
|
mod m20260317_000004_create_wanted_items;
|
||||||
mod m20260317_000005_create_download_queue;
|
mod m20260317_000005_create_download_queue;
|
||||||
mod m20260317_000006_create_search_cache;
|
mod m20260317_000006_create_search_cache;
|
||||||
|
mod m20260317_000007_unique_artist_album;
|
||||||
|
mod m20260317_000009_add_wanted_name;
|
||||||
|
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;
|
||||||
|
mod m20260320_000015_add_subsonic_password;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -19,6 +27,14 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260317_000004_create_wanted_items::Migration),
|
Box::new(m20260317_000004_create_wanted_items::Migration),
|
||||||
Box::new(m20260317_000005_create_download_queue::Migration),
|
Box::new(m20260317_000005_create_download_queue::Migration),
|
||||||
Box::new(m20260317_000006_create_search_cache::Migration),
|
Box::new(m20260317_000006_create_search_cache::Migration),
|
||||||
|
Box::new(m20260317_000007_unique_artist_album::Migration),
|
||||||
|
Box::new(m20260317_000009_add_wanted_name::Migration),
|
||||||
|
Box::new(m20260317_000010_add_wanted_mbid::Migration),
|
||||||
|
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),
|
||||||
|
Box::new(m20260320_000015_add_subsonic_password::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ pub async fn upsert(
|
|||||||
musicbrainz_id: Option<&str>,
|
musicbrainz_id: Option<&str>,
|
||||||
artist_id: Option<i32>,
|
artist_id: Option<i32>,
|
||||||
) -> DbResult<Album> {
|
) -> DbResult<Album> {
|
||||||
if let Some(mbid) = musicbrainz_id {
|
if let Some(mbid) = musicbrainz_id
|
||||||
if let Some(existing) = Albums::find()
|
&& let Some(existing) = Albums::find()
|
||||||
.filter(album::Column::MusicbrainzId.eq(mbid))
|
.filter(album::Column::MusicbrainzId.eq(mbid))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(existing) = find_by_name_and_artist(db, name, album_artist).await? {
|
if let Some(existing) = find_by_name_and_artist(db, name, album_artist).await? {
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to insert — catch unique constraint race
|
||||||
let active = ActiveModel {
|
let active = ActiveModel {
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
album_artist: Set(album_artist.to_string()),
|
album_artist: Set(album_artist.to_string()),
|
||||||
@@ -31,7 +31,21 @@ pub async fn upsert(
|
|||||||
artist_id: Set(artist_id),
|
artist_id: Set(artist_id),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
Ok(active.insert(db).await?)
|
match active.insert(db).await {
|
||||||
|
Ok(inserted) => Ok(inserted),
|
||||||
|
Err(DbErr::Exec(RuntimeErr::SqlxError(sqlx_err)))
|
||||||
|
if sqlx_err.to_string().contains("UNIQUE constraint failed") =>
|
||||||
|
{
|
||||||
|
find_by_name_and_artist(db, name, album_artist)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DbError::Other(format!(
|
||||||
|
"album '{name}' by '{album_artist}' vanished after conflict"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult<Album> {
|
pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult<Album> {
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ use sea_orm::*;
|
|||||||
use crate::entities::artist::{self, ActiveModel, Entity as Artists, Model as Artist};
|
use crate::entities::artist::{self, ActiveModel, Entity as Artists, Model as Artist};
|
||||||
use crate::error::{DbError, DbResult};
|
use crate::error::{DbError, DbResult};
|
||||||
|
|
||||||
pub async fn upsert(db: &DatabaseConnection, name: &str, musicbrainz_id: Option<&str>) -> DbResult<Artist> {
|
pub async fn upsert(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
name: &str,
|
||||||
|
musicbrainz_id: Option<&str>,
|
||||||
|
) -> DbResult<Artist> {
|
||||||
// Try to find by musicbrainz_id first, then by name
|
// Try to find by musicbrainz_id first, then by name
|
||||||
if let Some(mbid) = musicbrainz_id {
|
if let Some(mbid) = musicbrainz_id
|
||||||
if let Some(existing) = Artists::find()
|
&& let Some(existing) = Artists::find()
|
||||||
.filter(artist::Column::MusicbrainzId.eq(mbid))
|
.filter(artist::Column::MusicbrainzId.eq(mbid))
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(existing) = find_by_name(db, name).await? {
|
if let Some(existing) = find_by_name(db, name).await? {
|
||||||
@@ -26,6 +29,8 @@ pub async fn upsert(db: &DatabaseConnection, name: &str, musicbrainz_id: Option<
|
|||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to insert — if we race with another task, catch the unique constraint
|
||||||
|
// violation and fall back to a lookup.
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let active = ActiveModel {
|
let active = ActiveModel {
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
@@ -33,9 +38,22 @@ pub async fn upsert(db: &DatabaseConnection, name: &str, musicbrainz_id: Option<
|
|||||||
added_at: Set(now),
|
added_at: Set(now),
|
||||||
top_songs: Set("[]".to_string()),
|
top_songs: Set("[]".to_string()),
|
||||||
similar_artists: Set("[]".to_string()),
|
similar_artists: Set("[]".to_string()),
|
||||||
|
monitored: Set(false),
|
||||||
|
last_checked_at: Set(None),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
Ok(active.insert(db).await?)
|
match active.insert(db).await {
|
||||||
|
Ok(inserted) => Ok(inserted),
|
||||||
|
Err(DbErr::Exec(RuntimeErr::SqlxError(sqlx_err)))
|
||||||
|
if sqlx_err.to_string().contains("UNIQUE constraint failed") =>
|
||||||
|
{
|
||||||
|
// Lost the race — another task inserted first, just look it up
|
||||||
|
find_by_name(db, name)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Other(format!("artist '{name}' vanished after conflict")))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult<Artist> {
|
pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult<Artist> {
|
||||||
@@ -67,14 +85,22 @@ pub async fn update(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbR
|
|||||||
Ok(active.update(db).await?)
|
Ok(active.update(db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_top_songs(db: &DatabaseConnection, id: i32, top_songs_json: &str) -> DbResult<Artist> {
|
pub async fn update_top_songs(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
top_songs_json: &str,
|
||||||
|
) -> DbResult<Artist> {
|
||||||
let existing = get_by_id(db, id).await?;
|
let existing = get_by_id(db, id).await?;
|
||||||
let mut active: ActiveModel = existing.into();
|
let mut active: ActiveModel = existing.into();
|
||||||
active.top_songs = Set(top_songs_json.to_string());
|
active.top_songs = Set(top_songs_json.to_string());
|
||||||
Ok(active.update(db).await?)
|
Ok(active.update(db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_similar_artists(db: &DatabaseConnection, id: i32, similar_json: &str) -> DbResult<Artist> {
|
pub async fn update_similar_artists(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
similar_json: &str,
|
||||||
|
) -> DbResult<Artist> {
|
||||||
let existing = get_by_id(db, id).await?;
|
let existing = get_by_id(db, id).await?;
|
||||||
let mut active: ActiveModel = existing.into();
|
let mut active: ActiveModel = existing.into();
|
||||||
active.similar_artists = Set(similar_json.to_string());
|
active.similar_artists = Set(similar_json.to_string());
|
||||||
@@ -85,3 +111,25 @@ pub async fn delete(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
|||||||
Artists::delete_by_id(id).exec(db).await?;
|
Artists::delete_by_id(id).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_monitored(db: &DatabaseConnection, id: i32, monitored: bool) -> DbResult<Artist> {
|
||||||
|
let existing = get_by_id(db, id).await?;
|
||||||
|
let mut active: ActiveModel = existing.into();
|
||||||
|
active.monitored = Set(monitored);
|
||||||
|
Ok(active.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_monitored(db: &DatabaseConnection) -> DbResult<Vec<Artist>> {
|
||||||
|
Ok(Artists::find()
|
||||||
|
.filter(artist::Column::Monitored.eq(true))
|
||||||
|
.order_by_asc(artist::Column::Name)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_last_checked(db: &DatabaseConnection, id: i32) -> DbResult<Artist> {
|
||||||
|
let existing = get_by_id(db, id).await?;
|
||||||
|
let mut active: ActiveModel = existing.into();
|
||||||
|
active.last_checked_at = Set(Some(Utc::now().naive_utc()));
|
||||||
|
Ok(active.update(db).await?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ pub async fn purge_expired(db: &DatabaseConnection) -> DbResult<u64> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected)
|
Ok(result.rows_affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all cache entries whose key starts with the given prefix.
|
||||||
|
pub async fn purge_prefix(db: &DatabaseConnection, prefix: &str) -> DbResult<u64> {
|
||||||
|
let result = SearchCache::delete_many()
|
||||||
|
.filter(search_cache::Column::QueryKey.starts_with(prefix))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected)
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ pub async fn list(
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_wanted_item_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
wanted_item_id: i32,
|
||||||
|
) -> DbResult<Option<DownloadQueueItem>> {
|
||||||
|
Ok(DownloadQueue::find()
|
||||||
|
.filter(download_queue::Column::WantedItemId.eq(wanted_item_id))
|
||||||
|
.one(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn retry_failed(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
pub async fn retry_failed(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
||||||
let item = DownloadQueue::find_by_id(id)
|
let item = DownloadQueue::find_by_id(id)
|
||||||
.one(db)
|
.one(db)
|
||||||
@@ -76,7 +86,8 @@ pub async fn retry_failed(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
|||||||
let mut active: ActiveModel = item.into();
|
let mut active: ActiveModel = item.into();
|
||||||
active.status = Set(DownloadStatus::Pending);
|
active.status = Set(DownloadStatus::Pending);
|
||||||
active.error_message = Set(None);
|
active.error_message = Set(None);
|
||||||
active.retry_count = Set(active.retry_count.unwrap() + 1);
|
let current_retries = active.retry_count.take().unwrap_or(0);
|
||||||
|
active.retry_count = Set(current_retries + 1);
|
||||||
active.updated_at = Set(Utc::now().naive_utc());
|
active.updated_at = Set(Utc::now().naive_utc());
|
||||||
active.update(db).await?;
|
active.update(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2,5 +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 wanted;
|
pub mod wanted;
|
||||||
|
|||||||
178
src/queries/playlists.rs
Normal file
178
src/queries/playlists.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -93,7 +94,24 @@ pub async fn get_untagged(db: &DatabaseConnection) -> DbResult<Vec<Track>> {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_metadata(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbResult<Track> {
|
/// Get tracks that need metadata enrichment — either no MBID at all,
|
||||||
|
/// or have an MBID but are missing album info (e.g., freshly downloaded).
|
||||||
|
pub async fn get_needing_metadata(db: &DatabaseConnection) -> DbResult<Vec<Track>> {
|
||||||
|
Ok(Tracks::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(track::Column::MusicbrainzId.is_null())
|
||||||
|
.add(track::Column::AlbumId.is_null()),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_metadata(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
model: ActiveModel,
|
||||||
|
) -> DbResult<Track> {
|
||||||
let mut active = model;
|
let mut active = model;
|
||||||
active.id = Set(id);
|
active.id = Set(id);
|
||||||
active.updated_at = Set(Utc::now().naive_utc());
|
active.updated_at = Set(Utc::now().naive_utc());
|
||||||
@@ -104,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?)
|
||||||
|
}
|
||||||
|
|||||||
80
src/queries/users.rs
Normal file
80
src/queries/users.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::*;
|
||||||
|
|
||||||
|
use crate::entities::user::{self, ActiveModel, Entity as Users, Model as User, UserRole};
|
||||||
|
use crate::error::{DbError, DbResult};
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
role: UserRole,
|
||||||
|
) -> DbResult<User> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let active = ActiveModel {
|
||||||
|
username: Set(username.to_string()),
|
||||||
|
password_hash: Set(password_hash.to_string()),
|
||||||
|
role: Set(role),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Ok(active.insert(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_username(db: &DatabaseConnection, username: &str) -> DbResult<Option<User>> {
|
||||||
|
Ok(Users::find()
|
||||||
|
.filter(user::Column::Username.eq(username))
|
||||||
|
.one(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id(db: &DatabaseConnection, id: i32) -> DbResult<User> {
|
||||||
|
Users::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("user id={id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(db: &DatabaseConnection) -> DbResult<Vec<User>> {
|
||||||
|
Ok(Users::find()
|
||||||
|
.order_by_asc(user::Column::Username)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
||||||
|
Users::delete_by_id(id).exec(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count(db: &DatabaseConnection) -> DbResult<u64> {
|
||||||
|
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.
|
||||||
|
pub async fn adopt_orphaned_wanted_items(db: &DatabaseConnection, user_id: i32) -> DbResult<u64> {
|
||||||
|
use crate::entities::wanted_item;
|
||||||
|
let result = wanted_item::Entity::update_many()
|
||||||
|
.col_expr(
|
||||||
|
wanted_item::Column::UserId,
|
||||||
|
sea_orm::sea_query::Expr::value(user_id),
|
||||||
|
)
|
||||||
|
.filter(wanted_item::Column::UserId.is_null())
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected)
|
||||||
|
}
|
||||||
@@ -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::wanted_item::{
|
use crate::entities::wanted_item::{
|
||||||
@@ -6,19 +7,26 @@ use crate::entities::wanted_item::{
|
|||||||
};
|
};
|
||||||
use crate::error::{DbError, DbResult};
|
use crate::error::{DbError, DbResult};
|
||||||
|
|
||||||
pub async fn add(
|
pub struct AddWantedItem<'a> {
|
||||||
db: &DatabaseConnection,
|
pub item_type: ItemType,
|
||||||
item_type: ItemType,
|
pub name: &'a str,
|
||||||
artist_id: Option<i32>,
|
pub musicbrainz_id: Option<&'a str>,
|
||||||
album_id: Option<i32>,
|
pub artist_id: Option<i32>,
|
||||||
track_id: Option<i32>,
|
pub album_id: Option<i32>,
|
||||||
) -> DbResult<WantedItem> {
|
pub track_id: Option<i32>,
|
||||||
|
pub user_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add(db: &DatabaseConnection, item: AddWantedItem<'_>) -> DbResult<WantedItem> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let active = ActiveModel {
|
let active = ActiveModel {
|
||||||
item_type: Set(item_type),
|
item_type: Set(item.item_type),
|
||||||
artist_id: Set(artist_id),
|
name: Set(item.name.to_string()),
|
||||||
album_id: Set(album_id),
|
musicbrainz_id: Set(item.musicbrainz_id.map(String::from)),
|
||||||
track_id: Set(track_id),
|
artist_id: Set(item.artist_id),
|
||||||
|
album_id: Set(item.album_id),
|
||||||
|
track_id: Set(item.track_id),
|
||||||
|
user_id: Set(item.user_id),
|
||||||
status: Set(WantedStatus::Wanted),
|
status: Set(WantedStatus::Wanted),
|
||||||
added_at: Set(now),
|
added_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
@@ -30,11 +38,15 @@ pub async fn add(
|
|||||||
pub async fn list(
|
pub async fn list(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
status_filter: Option<WantedStatus>,
|
status_filter: Option<WantedStatus>,
|
||||||
|
user_id: Option<i32>,
|
||||||
) -> DbResult<Vec<WantedItem>> {
|
) -> DbResult<Vec<WantedItem>> {
|
||||||
let mut query = WantedItems::find();
|
let mut query = WantedItems::find();
|
||||||
if let Some(status) = status_filter {
|
if let Some(status) = status_filter {
|
||||||
query = query.filter(wanted_item::Column::Status.eq(status));
|
query = query.filter(wanted_item::Column::Status.eq(status));
|
||||||
}
|
}
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
query = query.filter(wanted_item::Column::UserId.eq(uid));
|
||||||
|
}
|
||||||
Ok(query.all(db).await?)
|
Ok(query.all(db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,3 +73,15 @@ pub async fn remove(db: &DatabaseConnection, id: i32) -> DbResult<()> {
|
|||||||
WantedItems::delete_by_id(id).exec(db).await?;
|
WantedItems::delete_by_id(id).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Promote all Downloaded items to Owned status. Returns the count updated.
|
||||||
|
pub async fn promote_downloaded_to_owned(db: &DatabaseConnection) -> DbResult<u64> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let result = WantedItems::update_many()
|
||||||
|
.col_expr(wanted_item::Column::Status, Expr::value("owned"))
|
||||||
|
.col_expr(wanted_item::Column::UpdatedAt, Expr::value(now))
|
||||||
|
.filter(wanted_item::Column::Status.eq(WantedStatus::Downloaded))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ use shanty_db::entities::wanted_item::{ItemType, WantedStatus};
|
|||||||
use shanty_db::{Database, queries};
|
use shanty_db::{Database, queries};
|
||||||
|
|
||||||
async fn test_db() -> Database {
|
async fn test_db() -> Database {
|
||||||
Database::new("sqlite::memory:").await.expect("failed to create test database")
|
Database::new("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("failed to create test database")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -163,19 +165,30 @@ async fn test_wanted_items_lifecycle() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Add wanted item
|
// Add wanted item
|
||||||
let item = queries::wanted::add(conn, ItemType::Artist, Some(artist.id), None, None)
|
let item = queries::wanted::add(
|
||||||
.await
|
conn,
|
||||||
.unwrap();
|
queries::wanted::AddWantedItem {
|
||||||
|
item_type: ItemType::Artist,
|
||||||
|
name: "Radiohead",
|
||||||
|
musicbrainz_id: None,
|
||||||
|
artist_id: Some(artist.id),
|
||||||
|
album_id: None,
|
||||||
|
track_id: None,
|
||||||
|
user_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(item.status, WantedStatus::Wanted);
|
assert_eq!(item.status, WantedStatus::Wanted);
|
||||||
assert_eq!(item.item_type, ItemType::Artist);
|
assert_eq!(item.item_type, ItemType::Artist);
|
||||||
|
|
||||||
// List with filter
|
// List with filter
|
||||||
let wanted = queries::wanted::list(conn, Some(WantedStatus::Wanted))
|
let wanted = queries::wanted::list(conn, Some(WantedStatus::Wanted), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(wanted.len(), 1);
|
assert_eq!(wanted.len(), 1);
|
||||||
|
|
||||||
let downloaded = queries::wanted::list(conn, Some(WantedStatus::Downloaded))
|
let downloaded = queries::wanted::list(conn, Some(WantedStatus::Downloaded), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(downloaded.is_empty());
|
assert!(downloaded.is_empty());
|
||||||
@@ -188,7 +201,7 @@ async fn test_wanted_items_lifecycle() {
|
|||||||
|
|
||||||
// Remove
|
// Remove
|
||||||
queries::wanted::remove(conn, item.id).await.unwrap();
|
queries::wanted::remove(conn, item.id).await.unwrap();
|
||||||
let all = queries::wanted::list(conn, None).await.unwrap();
|
let all = queries::wanted::list(conn, None, None).await.unwrap();
|
||||||
assert!(all.is_empty());
|
assert!(all.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,14 +232,9 @@ async fn test_download_queue_lifecycle() {
|
|||||||
assert!(next.is_none());
|
assert!(next.is_none());
|
||||||
|
|
||||||
// Fail it
|
// Fail it
|
||||||
queries::downloads::update_status(
|
queries::downloads::update_status(conn, item.id, DownloadStatus::Failed, Some("network error"))
|
||||||
conn,
|
.await
|
||||||
item.id,
|
.unwrap();
|
||||||
DownloadStatus::Failed,
|
|
||||||
Some("network error"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// List failed
|
// List failed
|
||||||
let failed = queries::downloads::list(conn, Some(DownloadStatus::Failed))
|
let failed = queries::downloads::list(conn, Some(DownloadStatus::Failed))
|
||||||
@@ -236,7 +244,9 @@ async fn test_download_queue_lifecycle() {
|
|||||||
assert_eq!(failed[0].error_message.as_deref(), Some("network error"));
|
assert_eq!(failed[0].error_message.as_deref(), Some("network error"));
|
||||||
|
|
||||||
// Retry
|
// Retry
|
||||||
queries::downloads::retry_failed(conn, item.id).await.unwrap();
|
queries::downloads::retry_failed(conn, item.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let pending = queries::downloads::list(conn, Some(DownloadStatus::Pending))
|
let pending = queries::downloads::list(conn, Some(DownloadStatus::Pending))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -274,4 +284,24 @@ async fn test_search_cache_ttl() {
|
|||||||
// Purge expired
|
// Purge expired
|
||||||
let purged = queries::cache::purge_expired(conn).await.unwrap();
|
let purged = queries::cache::purge_expired(conn).await.unwrap();
|
||||||
assert_eq!(purged, 1);
|
assert_eq!(purged, 1);
|
||||||
|
|
||||||
|
// Purge by prefix
|
||||||
|
queries::cache::set(conn, "artist_totals:1", "computed", "[10,5,3]", 3600)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
queries::cache::set(conn, "artist_totals:2", "computed", "[20,10,5]", 3600)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
queries::cache::set(conn, "other_key", "computed", "{}", 3600)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let purged = queries::cache::purge_prefix(conn, "artist_totals:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(purged, 2);
|
||||||
|
|
||||||
|
// other_key should still exist
|
||||||
|
let result = queries::cache::get(conn, "other_key").await.unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user