refactor: sqlx -> sea-orm; let's get a bit fancier.

This commit is contained in:
Lys 2023-10-09 21:49:57 +03:00
parent d575c317c3
commit 7198eda4ee
Signed by: lyssieth
GPG key ID: C9CF3D614FAA3940
44 changed files with 2264 additions and 580 deletions

View file

@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM users WHERE name = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "password",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "d08992cf2c132fedbed21b94d545e154fa2a7a2a2bf79fd033341d1bb5a6c0f2"
}

1320
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,20 @@
[package] [workspace]
name = "rave" members = ["app", "entities", "migration"]
version = "0.1.0" resolver = "2"
edition = "2021"
publish = ["crates-io"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace.dependencies]
entities = { path = "entities" }
[dependencies] migration = { path = "migration" }
cfg-if = "1.0.0" sea-orm = { version = "0.12", features = [
color-eyre = "0.6.2" "sqlx-postgres",
md5 = "0.7.0" "runtime-tokio-rustls",
poem = { version = "1.3.58", features = [ "with-time",
"compression", "postgres-array",
"cookie",
"session",
"static-files",
"xml",
] } ] }
quick-xml = { version = "0.30.0", features = ["serialize"] }
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.107"
sqlx = { version = "0.7.2", features = ["time", "postgres", "runtime-tokio"] }
time = { version = "0.3.29", features = [ time = { version = "0.3.29", features = [
"serde-human-readable", "serde-human-readable",
"macros", "macros",
"parsing", "parsing",
] } ] }
tokio = { version = "1.32.0", features = ["full"] }
tracing = { version = "0.1.37", features = ["async-await"] } tracing = { version = "0.1.37", features = ["async-await"] }
tracing-subscriber = { version = "0.3.17", features = [ serde = { version = "1.0.188", features = ["derive"] }
"env-filter",
"tracing",
"parking_lot",
"time",
] }
url = { version = "2.4.1", features = ["serde"] }
url-escape = "0.1.1"

37
app/Cargo.toml Normal file
View file

@ -0,0 +1,37 @@
[package]
name = "rave"
version = "0.1.0"
edition = "2021"
publish = ["forge-lys-ee"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cfg-if = "1.0.0"
color-eyre = "0.6.2"
md5 = "0.7.0"
poem = { version = "1.3.58", features = [
"compression",
"cookie",
"session",
"static-files",
"xml",
] }
poem-ext = "0.9.4"
quick-xml = { version = "0.30.0", features = ["serialize"] }
serde = { workspace = true }
serde_json = "1.0.107"
time = { workspace = true }
tokio = { version = "1.32.0", features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3.17", features = [
"env-filter",
"tracing",
"parking_lot",
"time",
] }
url = { version = "2.4.1", features = ["serde"] }
url-escape = "0.1.1"
sea-orm = { workspace = true }
entities = { workspace = true }
migration = { workspace = true }

View file

@ -5,13 +5,15 @@
use std::time::Duration; use std::time::Duration;
use color_eyre::Result; use color_eyre::Result;
use migration::{Migrator, MigratorTrait};
use poem::{ use poem::{
listener::TcpListener, listener::TcpListener,
middleware, middleware,
web::{CompressionAlgo, CompressionLevel}, web::{CompressionAlgo, CompressionLevel},
Endpoint, EndpointExt, Route, Endpoint, EndpointExt, Route,
}; };
use sqlx::PgPool; use poem_ext::db::DbTransactionMiddleware;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use tracing::info; use tracing::info;
use tracing_subscriber::{fmt, EnvFilter}; use tracing_subscriber::{fmt, EnvFilter};
@ -20,7 +22,6 @@ mod random_types;
mod rest; mod rest;
mod subsonic; mod subsonic;
mod ui; mod ui;
mod user;
mod utils; mod utils;
const LISTEN: &str = "0.0.0.0:1234"; const LISTEN: &str = "0.0.0.0:1234";
@ -32,11 +33,10 @@ async fn main() -> Result<()> {
let route = create_route(); let route = create_route();
let pool = create_pool().await; let dbc = create_pool().await?;
Migrator::up(&dbc, None).await?;
sqlx::migrate!().run(&pool).await?; let route = route.with(DbTransactionMiddleware::new(dbc));
let route = route.with(utils::middleware::DbConnectionMiddleware::new(pool));
let server = create_server(); let server = create_server();
@ -51,12 +51,16 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
async fn create_pool() -> PgPool {
async fn create_pool() -> Result<DatabaseConnection> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL not set"); let url = std::env::var("DATABASE_URL").expect("DATABASE_URL not set");
PgPool::connect(&url) let mut opt = ConnectOptions::new(url);
.await opt.max_connections(100)
.expect("Failed to connect to database") .min_connections(5)
.sqlx_logging(true);
Ok(Database::connect(opt).await?)
} }
fn create_route() -> Box<dyn Endpoint<Output = poem::Response>> { fn create_route() -> Box<dyn Endpoint<Output = poem::Response>> {

30
app/src/rest/get_album.rs Normal file
View file

@ -0,0 +1,30 @@
use crate::{
authentication::Authentication,
subsonic::SubsonicResponse,
utils::{self},
};
use poem::web::{Data, Query};
use poem_ext::db::DbTxn;
use serde::Deserialize;
#[poem::handler]
pub async fn get_album(
Data(txn): Data<&DbTxn>,
auth: Authentication,
Query(params): Query<GetAlbumParams>,
) -> SubsonicResponse {
let u = utils::verify_user(txn.clone(), auth).await;
match u {
Ok(_) => {}
Err(e) => return e,
}
todo!("get_album not implemented");
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetAlbumParams {
pub id: i32,
}

View file

@ -0,0 +1,263 @@
#![allow(clippy::unused_async)] // todo: remove
use entities::{album, prelude::Album};
use poem::web::{Data, Query};
use poem_ext::db::DbTxn;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use serde::Deserialize;
use crate::{
authentication::Authentication,
random_types::SortType,
subsonic::{Error, SubsonicResponse},
utils::{self},
};
macro_rules! error_or {
($thing:ident) => {
match $thing {
Ok(a) => Ok(a),
Err(e) => Err(Error::Generic(Some(e.to_string()))),
}
};
}
#[poem::handler]
pub async fn get_album_list(
Data(txn): Data<&DbTxn>,
auth: Authentication,
Query(params): Query<GetAlbumListParams>,
) -> SubsonicResponse {
let txn = txn.clone();
let u = utils::verify_user(txn.clone(), auth).await;
match u {
Ok(_) => {}
Err(e) => return e,
}
let params = match params.verify() {
Ok(p) => p,
Err(e) => return e,
};
let album_list = match params.r#type {
SortType::Random => get_album_list_random(txn, params).await,
SortType::Newest => get_album_list_newest(txn, params).await,
SortType::Highest => get_album_list_highest(txn, params).await,
SortType::Frequent => get_album_list_frequent(txn, params).await,
SortType::Recent => get_album_list_recent(txn, params).await,
SortType::AlphabeticalByName => get_album_list_alphabetical_by_name(txn, params).await,
SortType::AlphabeticalByArtist => get_album_list_alphabetical_by_artist(txn, params).await,
SortType::Starred => get_album_list_starred(txn, params).await,
SortType::ByYear => get_album_list_by_year(txn, params).await,
SortType::ByGenre => get_album_list_by_genre(txn, params).await,
};
match album_list {
Ok(a) => SubsonicResponse::new_album_list(a),
Err(e) => SubsonicResponse::new_error(e),
}
}
#[allow(unused_variables)]
async fn get_album_list_random(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
Err(Error::Generic(Some(
"Sorting by random not implemented".to_string(),
)))
}
#[allow(unused_variables)]
async fn get_album_list_newest(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let albums = Album::find()
.order_by_desc(album::Column::Created)
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[allow(unused_variables)]
async fn get_album_list_highest(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
Err(Error::Generic(Some(
"Sorting by highest rating not implemented".to_string(),
)))
}
#[allow(unused_variables)]
async fn get_album_list_frequent(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let albums = Album::find()
.order_by_desc(album::Column::PlayCount)
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[allow(unused_variables)]
async fn get_album_list_recent(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
Err(Error::Generic(Some(
"Sorting by recently played not implemented".to_string(),
)))
}
#[allow(unused_variables)]
async fn get_album_list_alphabetical_by_name(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let albums = Album::find()
.order_by_desc(album::Column::Name)
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[allow(unused_variables)]
async fn get_album_list_alphabetical_by_artist(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let albums = Album::find()
.order_by_desc(album::Column::Artist)
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[allow(unused_variables)]
async fn get_album_list_starred(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let albums = Album::find()
.filter(album::Column::Starred.is_not_null())
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
async fn get_album_list_by_year(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let from_year = params.from_year;
let to_year = params.to_year;
let (Some(from_year), Some(to_year)) = (from_year, to_year) else {
return Err(Error::RequiredParameterMissing(Some(
"Missing required parameter: fromYear or toYear".to_string(),
)));
};
let albums = Album::find()
.filter(album::Column::Year.is_not_null())
.filter(album::Column::Year.between(from_year, to_year))
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[allow(unused_variables)]
async fn get_album_list_by_genre(
conn: DbTxn,
params: GetAlbumListParams,
) -> Result<Vec<album::Model>, Error> {
let genre = params.genre;
let Some(genre) = genre else {
return Err(Error::RequiredParameterMissing(Some(
"Missing required parameter: genre".to_string(),
)));
};
let albums = Album::find()
.filter(album::Column::Genre.is_not_null())
.filter(album::Column::Genre.eq(genre))
.limit(params.size)
.offset(params.offset)
.all(&*conn)
.await;
error_or!(albums)
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetAlbumListParams {
#[serde(rename = "type")]
pub r#type: SortType,
#[serde(default = "default_size")]
pub size: u64,
#[serde(default)]
pub offset: u64,
#[serde(default)]
pub from_year: Option<i32>,
#[serde(default)]
pub to_year: Option<i32>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default)]
pub music_folder_id: Option<u64>,
}
impl GetAlbumListParams {
#[allow(clippy::result_large_err)]
pub fn verify(self) -> Result<Self, SubsonicResponse> {
if self.r#type == SortType::ByYear {
if self.from_year.is_none() || self.to_year.is_none() {
return Err(SubsonicResponse::new_error(
Error::RequiredParameterMissing(Some(
"Missing required parameter: fromYear or toYear".to_string(),
)),
));
}
} else if self.r#type == SortType::ByGenre && self.genre.is_none() {
return Err(SubsonicResponse::new_error(
Error::RequiredParameterMissing(Some(
"Missing required parameter: genre".to_string(),
)),
));
} else if self.size > 500 || self.size < 1 {
return Err(SubsonicResponse::new_error(Error::Generic(Some(
"size must be between 1 and 500".to_string(),
))));
}
Ok(self)
}
}
const fn default_size() -> u64 {
10
}

View file

@ -1,14 +1,15 @@
use poem::web::Data; use poem::web::Data;
use poem_ext::db::DbTxn;
use crate::{ use crate::{
authentication::Authentication, authentication::Authentication,
subsonic::SubsonicResponse, subsonic::SubsonicResponse,
utils::{self, middleware::DbConn}, utils::{self},
}; };
#[poem::handler] #[poem::handler]
pub async fn get_license(Data(conn): Data<&DbConn>, auth: Authentication) -> SubsonicResponse { pub async fn get_license(Data(txn): Data<&DbTxn>, auth: Authentication) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await; let u = utils::verify_user(txn.clone(), auth).await;
match u { match u {
Ok(_) => {} Ok(_) => {}

View file

@ -1,17 +1,15 @@
use poem::web::Data; use poem::web::Data;
use poem_ext::db::DbTxn;
use crate::{ use crate::{
authentication::Authentication, authentication::Authentication,
subsonic::{MusicFolder, SubsonicResponse}, subsonic::{MusicFolder, SubsonicResponse},
utils::{self, middleware::DbConn}, utils::{self},
}; };
#[poem::handler] #[poem::handler]
pub async fn get_music_folders( pub async fn get_music_folders(Data(txn): Data<&DbTxn>, auth: Authentication) -> SubsonicResponse {
Data(conn): Data<&DbConn>, let u = utils::verify_user(txn.clone(), auth).await;
auth: Authentication,
) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await;
match u { match u {
Ok(_) => {} Ok(_) => {}

View file

@ -8,8 +8,6 @@ mod get_music_folders;
mod ping; mod ping;
// rest/getAlbumList // rest/getAlbumList
mod get_album_list; mod get_album_list;
// rest/getAlbumList2
mod get_album_list2;
// rest/getAlbum // rest/getAlbum
mod get_album; mod get_album;
// rest/stream // rest/stream
@ -21,7 +19,7 @@ pub fn build() -> Box<dyn Endpoint<Output = poem::Response>> {
.at("/getLicense", get_license::get_license) .at("/getLicense", get_license::get_license)
.at("/getMusicFolders", get_music_folders::get_music_folders) .at("/getMusicFolders", get_music_folders::get_music_folders)
.at("/getAlbumList", get_album_list::get_album_list) .at("/getAlbumList", get_album_list::get_album_list)
.at("/getAlbumList2", get_album_list2::get_album_list2) .at("/getAlbumList2", get_album_list::get_album_list)
.at("/getAlbum", get_album::get_album) .at("/getAlbum", get_album::get_album)
.at("/stream", stream::stream) .at("/stream", stream::stream)
.boxed() .boxed()

View file

@ -1,14 +1,15 @@
use poem::web::Data; use poem::web::Data;
use poem_ext::db::DbTxn;
use crate::{ use crate::{
authentication::Authentication, authentication::Authentication,
subsonic::SubsonicResponse, subsonic::SubsonicResponse,
utils::{self, middleware::DbConn}, utils::{self},
}; };
#[poem::handler] #[poem::handler]
pub async fn ping(Data(conn): Data<&DbConn>, auth: Authentication) -> SubsonicResponse { pub async fn ping(Data(txn): Data<&DbTxn>, auth: Authentication) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await; let u = utils::verify_user(txn.clone(), auth).await;
match u { match u {
Ok(_) => SubsonicResponse::new_empty(), Ok(_) => SubsonicResponse::new_empty(),

View file

@ -3,22 +3,23 @@ use poem::{
web::{Data, Query}, web::{Data, Query},
IntoResponse, Response, IntoResponse, Response,
}; };
use poem_ext::db::DbTxn;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
authentication::Authentication, authentication::Authentication,
utils::{self, middleware::DbConn}, utils::{self},
}; };
const SONG: &[u8] = include_bytes!("../../../data.mp3"); const SONG: &[u8] = include_bytes!("../../../../data.mp3");
#[poem::handler] #[poem::handler]
pub async fn stream( pub async fn stream(
Data(conn): Data<&DbConn>, Data(txn): Data<&DbTxn>,
auth: Authentication, auth: Authentication,
Query(_params): Query<StreamParams>, Query(_params): Query<StreamParams>,
) -> Response { ) -> Response {
let u = utils::verify_user(conn.clone(), auth).await; let u = utils::verify_user(txn.clone(), auth).await;
match u { match u {
Ok(_) => {} Ok(_) => {}

View file

@ -2,8 +2,9 @@
use std::fmt::Display; use std::fmt::Display;
use entities::album;
use poem::{http::StatusCode, IntoResponse, Response}; use poem::{http::StatusCode, IntoResponse, Response};
use serde::{ser::SerializeStruct, Serialize, Serializer}; use serde::{ser::SerializeStruct, Serialize};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::authentication::VersionTriple; use crate::authentication::VersionTriple;
@ -42,16 +43,16 @@ impl SubsonicResponse {
Self::new(SubResponseType::MusicFolders { music_folders }) Self::new(SubResponseType::MusicFolders { music_folders })
} }
pub fn new_album_list(albums: Vec<Child>) -> Self { pub fn new_album_list(albums: Vec<album::Model>) -> Self {
Self::new(SubResponseType::AlbumList { albums }) Self::new(SubResponseType::AlbumList { albums })
} }
pub fn new_album_list2(albums: Vec<AlbumId3>) -> Self { pub fn new_album_list2(albums: Vec<album::Model>) -> Self {
Self::new(SubResponseType::AlbumList2 { albums }) Self::new(SubResponseType::AlbumList2 { albums })
} }
pub fn new_album(album: AlbumId3) -> Self { pub fn new_album(album: album::Model, songs: Vec<Child>) -> Self {
Self::new(SubResponseType::Album(album)) Self::new(SubResponseType::Album { album, songs })
} }
pub fn new_empty() -> Self { pub fn new_empty() -> Self {
@ -85,23 +86,29 @@ pub enum SubResponseType {
#[serde(rename = "albumList")] #[serde(rename = "albumList")]
AlbumList { AlbumList {
#[serde(rename = "album")] #[serde(rename = "album")]
albums: Vec<Child>, albums: Vec<album::Model>,
}, },
#[serde(rename = "albumList2")] #[serde(rename = "albumList2")]
AlbumList2 { AlbumList2 {
#[serde(rename = "album")] #[serde(rename = "album")]
albums: Vec<AlbumId3>, albums: Vec<album::Model>,
}, },
#[serde(rename = "album")] #[serde(rename = "album")]
Album(AlbumId3), Album {
#[serde(flatten)]
album: album::Model,
#[serde(flatten)]
songs: Vec<Child>,
},
Empty, Empty,
} }
#[derive(Debug, Clone, Serialize, Default)] #[derive(Debug, Clone, Serialize, Default)]
#[serde(default)]
pub struct AlbumId3 { pub struct AlbumId3 {
#[serde(rename = "@id", serialize_with = "album_id")] #[serde(rename = "@id", serialize_with = "crate::utils::album_id")]
pub id: i32, pub id: i64,
#[serde(rename = "@parent")] #[serde(rename = "@name")]
pub name: String, pub name: String,
#[serde(rename = "@artist", skip_serializing_if = "Option::is_none")] #[serde(rename = "@artist", skip_serializing_if = "Option::is_none")]
pub artist: Option<String>, pub artist: Option<String>,
@ -123,15 +130,8 @@ pub struct AlbumId3 {
pub year: Option<i32>, pub year: Option<i32>,
#[serde(rename = "@genre", skip_serializing_if = "Option::is_none")] #[serde(rename = "@genre", skip_serializing_if = "Option::is_none")]
pub genre: Option<String>, pub genre: Option<String>,
#[serde(rename = "song", skip_serializing_if = "Vec::is_empty")] #[serde(rename = "@musicFolder", skip_serializing_if = "Option::is_none")]
pub songs: Vec<Child>, pub folder_id: Option<i64>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn album_id<S: Serializer>(id: &i32, s: S) -> Result<S::Ok, S::Error> {
let str = format!("al-{id}");
s.serialize_str(&str)
} }
#[derive(Debug, Clone, Serialize, Default)] #[derive(Debug, Clone, Serialize, Default)]
@ -219,7 +219,7 @@ pub enum MediaType {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct MusicFolder { pub struct MusicFolder {
#[serde(rename = "@id")] #[serde(rename = "@id")]
pub id: i32, pub id: i64,
#[serde(rename = "@name")] #[serde(rename = "@name")]
pub name: String, pub name: String,
} }

51
app/src/utils.rs Normal file
View file

@ -0,0 +1,51 @@
use entities::{prelude::User, user};
use poem_ext::db::DbTxn;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Serializer;
use tracing::error;
use crate::{
authentication::Authentication,
subsonic::{Error, SubsonicResponse},
};
pub async fn verify_user(
conn: DbTxn,
auth: Authentication,
) -> Result<user::Model, SubsonicResponse> {
let user = User::find()
.filter(user::Column::Name.eq(&auth.username))
.one(&*conn)
.await;
match user {
Ok(Some(u)) => {
let ours = md5::compute(format!("{}{}", u.password, auth.salt));
let ours = format!("{ours:x}");
if ours == auth.token {
Ok(u)
} else {
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
)))
}
}
Ok(None) => Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
))),
Err(e) => {
error!("Error getting user: {e}");
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
)))
}
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
pub fn album_id<S: Serializer>(id: &i64, s: S) -> Result<S::Ok, S::Error> {
let str = format!("al-{id}");
s.serialize_str(&str)
}

View file

@ -1,5 +0,0 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

12
entities/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "entities"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
sea-orm = { workspace = true }
time = { workspace = true }
serde = { workspace = true }

57
entities/src/album.rs Normal file
View file

@ -0,0 +1,57 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "album")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
pub artist: Option<String>,
pub artist_id: Option<i64>,
pub cover_art: Option<String>,
pub song_count: i32,
pub duration: i64,
pub play_count: i64,
pub created: DateTimeWithTimeZone,
pub starred: Option<DateTimeWithTimeZone>,
pub year: Option<i32>,
pub genre: Option<String>,
pub music_folder_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::artist::Entity",
from = "Column::ArtistId",
to = "super::artist::Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
Artist,
#[sea_orm(
belongs_to = "super::music_folder::Entity",
from = "Column::MusicFolderId",
to = "super::music_folder::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
MusicFolder,
}
impl Related<super::artist::Entity> for Entity {
fn to() -> RelationDef {
Relation::Artist.def()
}
}
impl Related<super::music_folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::MusicFolder.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

31
entities/src/artist.rs Normal file
View file

@ -0,0 +1,31 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "artist")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub name: String,
pub cover_art: Option<String>,
pub artist_image_url: Option<String>,
pub album_count: i32,
pub starred: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::album::Entity")]
Album,
}
impl Related<super::album::Entity> for Entity {
fn to() -> RelationDef {
Relation::Album.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

8
entities/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
pub mod prelude;
pub mod album;
pub mod artist;
pub mod music_folder;
pub mod user;

View file

@ -0,0 +1,27 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "music_folder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::album::Entity")]
Album,
}
impl Related<super::album::Entity> for Entity {
fn to() -> RelationDef {
Relation::Album.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

6
entities/src/prelude.rs Normal file
View file

@ -0,0 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
pub use super::album::Entity as Album;
pub use super::artist::Entity as Artist;
pub use super::music_folder::Entity as MusicFolder;
pub use super::user::Entity as User;

20
entities/src/user.rs Normal file
View file

@ -0,0 +1,20 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub name: String,
pub password: String,
pub is_admin: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

16
migration/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "0.12"
features = ["runtime-tokio-rustls", "sqlx-postgres"]

41
migration/README.md Normal file
View file

@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

20
migration/src/lib.rs Normal file
View file

@ -0,0 +1,20 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_user;
mod m20231009_181004_create_music_folder;
mod m20231009_181104_create_artist;
mod m20231009_181346_create_album;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_create_user::Migration),
Box::new(m20231009_181004_create_music_folder::Migration),
Box::new(m20231009_181104_create_artist::Migration),
Box::new(m20231009_181346_create_album::Migration),
]
}
}

View file

@ -0,0 +1,68 @@
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(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.big_integer()
.not_null()
.primary_key()
.auto_increment()
.unique_key(),
)
.col(ColumnDef::new(User::Name).string().not_null().unique_key())
.col(ColumnDef::new(User::Password).string().not_null())
.col(
ColumnDef::new(User::IsAdmin)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.table(User::Table)
.col(User::Name)
.to_owned(),
)
.await?;
let query = Query::insert()
.into_table(User::Table)
.columns([User::Name, User::Password, User::IsAdmin])
.values_panic(["admin".into(), "admin".into(), true.into()])
.to_owned();
manager.exec_stmt(query).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum User {
Table,
Id,
Name,
Password,
IsAdmin,
}

View file

@ -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
.create_table(
Table::create()
.table(MusicFolder::Table)
.if_not_exists()
.col(
ColumnDef::new(MusicFolder::Id)
.big_integer()
.not_null()
.primary_key()
.auto_increment()
.unique_key(),
)
.col(
ColumnDef::new(MusicFolder::Name)
.string()
.not_null()
.unique_key(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MusicFolder::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum MusicFolder {
Table,
Id,
Name,
}

View file

@ -0,0 +1,63 @@
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(Artist::Table)
.if_not_exists()
.col(
ColumnDef::new(Artist::Id)
.big_integer()
.not_null()
.primary_key()
.auto_increment()
.unique_key(),
)
.col(
ColumnDef::new(Artist::Name)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Artist::CoverArt).string().null())
.col(ColumnDef::new(Artist::ArtistImageUrl).string().null())
.col(
ColumnDef::new(Artist::AlbumCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Artist::Starred)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Artist::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Artist {
Table,
Id,
Name,
CoverArt,
ArtistImageUrl,
AlbumCount,
Starred,
}

View file

@ -0,0 +1,104 @@
use sea_orm_migration::prelude::*;
use crate::{
m20231009_181004_create_music_folder::MusicFolder, m20231009_181104_create_artist::Artist,
};
#[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(Album::Table)
.if_not_exists()
.col(
ColumnDef::new(Album::Id)
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Album::Name).string().not_null())
.col(ColumnDef::new(Album::Artist).string().null())
.col(ColumnDef::new(Album::ArtistId).big_integer().null())
.col(ColumnDef::new(Album::CoverArt).string().null())
.col(ColumnDef::new(Album::SongCount).integer().not_null())
.col(ColumnDef::new(Album::Duration).big_integer().not_null())
.col(
ColumnDef::new(Album::PlayCount)
.big_integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Album::Created)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Album::Starred)
.timestamp_with_time_zone()
.null(),
)
.col(ColumnDef::new(Album::Year).integer().null())
.col(ColumnDef::new(Album::Genre).string().null())
.col(ColumnDef::new(Album::MusicFolderId).big_integer().null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.from_tbl(Album::Table)
.from_col(Album::MusicFolderId)
.to_tbl(MusicFolder::Table)
.to_col(MusicFolder::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.from_tbl(Album::Table)
.from_col(Album::ArtistId)
.to_tbl(Artist::Table)
.to_col(Artist::Id)
.on_delete(ForeignKeyAction::SetNull)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Album::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Album {
Table,
Id,
Name,
Artist,
ArtistId,
CoverArt,
SongCount,
Duration,
PlayCount,
Created,
Starred,
Year,
Genre,
MusicFolderId,
}

6
migration/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View file

@ -1,9 +0,0 @@
-- Add migration script here
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO users (id, name, password, is_admin)
VALUES (0, 'admin', 'admin', TRUE);

View file

@ -1,15 +0,0 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS album (
id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
artist TEXT,
artist_id INTEGER,
cover_art TEXT,
song_count INTEGER,
duration INTEGER,
play_count INTEGER,
created timestamptz DEFAULT current_timestamp,
starred timestamptz DEFAULT current_timestamp,
year INTEGER,
genre TEXT
);

View file

@ -1,21 +0,0 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS tracks (
id BIGSERIAL NOT NULL PRIMARY KEY,
parent INTEGER NOT NULL REFERENCES album(id),
is_dir BOOLEAN NOT NULL DEFAULT FALSE,
title TEXT NOT NULL,
album TEXT,
artist TEXT,
track INTEGER,
year INTEGER,
genre TEXT,
cover_art TEXT,
duration INTEGER,
path TEXT,
play_count INTEGER,
created timestamptz DEFAULT current_timestamp,
starred timestamptz,
album_id TEXT,
artist_id TEXT,
disc_number INTEGER
)

View file

@ -1,121 +0,0 @@
use crate::{
authentication::Authentication,
subsonic::{AlbumId3, Child, Error, MediaType, SubsonicResponse},
utils::{self, middleware::DbConn},
};
use poem::web::{Data, Query};
use serde::Deserialize;
#[poem::handler]
pub async fn get_album(
Data(conn): Data<&DbConn>,
auth: Authentication,
Query(params): Query<GetAlbumParams>,
) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await;
match u {
Ok(_) => {}
Err(e) => return e,
}
let mut count = 0;
let album = match params.id {
11 => AlbumId3 {
id: 11,
name: "Example".to_string(),
artist: Some("Example".to_string()),
song_count: 5,
duration: 100,
songs: vec![
Child {
id: "tr-111".to_string(),
title: "Example - 1".to_string(),
album: Some("Example".to_string()),
duration: Some(20),
content_type: Some("audio/mpeg".to_string()),
r#type: Some(MediaType::Music),
track: Some({
count += 1;
count
}),
..Default::default()
},
Child {
id: "tr-112".to_string(),
title: "Example - 2".to_string(),
album: Some("Example".to_string()),
duration: Some(20),
content_type: Some("audio/mpeg".to_string()),
r#type: Some(MediaType::Music),
track: Some({
count += 1;
count
}),
..Default::default()
},
Child {
id: "tr-113".to_string(),
title: "Example - 3".to_string(),
album: Some("Example".to_string()),
duration: Some(20),
content_type: Some("audio/mpeg".to_string()),
r#type: Some(MediaType::Music),
track: Some({
count += 1;
count
}),
..Default::default()
},
Child {
id: "tr-114".to_string(),
title: "Example - 4".to_string(),
album: Some("Example".to_string()),
duration: Some(20),
content_type: Some("audio/mpeg".to_string()),
r#type: Some(MediaType::Music),
track: Some({
count += 1;
count
}),
..Default::default()
},
Child {
id: "tr-115".to_string(),
title: "Example - 5".to_string(),
album: Some("Example".to_string()),
duration: Some(20),
content_type: Some("audio/mpeg".to_string()),
r#type: Some(MediaType::Music),
track: Some({
count += 1;
count
}),
..Default::default()
},
],
..Default::default()
},
12 => AlbumId3 {
id: 12,
name: "Example 2".to_string(),
artist: Some("Example 2".to_string()),
song_count: 7,
duration: 200,
..Default::default()
},
_ => {
return SubsonicResponse::new_error(Error::RequestedDataWasNotFound(Some(
"Album does not exist".to_string(),
)))
}
};
SubsonicResponse::new_album(album)
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetAlbumParams {
pub id: i32,
}

View file

@ -1,98 +0,0 @@
use poem::web::{Data, Query};
use serde::Deserialize;
use crate::{
authentication::Authentication,
random_types::SortType,
subsonic::{Child, Error, SubsonicResponse},
utils::{self, middleware::DbConn},
};
#[poem::handler]
pub async fn get_album_list(
Data(conn): Data<&DbConn>,
auth: Authentication,
Query(params): Query<GetAlbumListParams>,
) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await;
match u {
Ok(_) => {}
Err(e) => return e,
}
let _params = match params.verify() {
Ok(p) => p,
Err(e) => return e,
};
let album_list = vec![
Child {
id: "al-11".to_string(),
parent: Some(1),
title: "Example".to_string(),
artist: Some("Example".to_string()),
is_dir: true,
..Default::default()
},
Child {
id: "al-12".to_string(),
parent: Some(1),
title: "Example 2".to_string(),
artist: Some("Example 2".to_string()),
is_dir: true,
..Default::default()
},
];
SubsonicResponse::new_album_list(album_list)
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetAlbumListParams {
#[serde(rename = "type")]
pub r#type: SortType,
#[serde(default = "default_size")]
pub size: i32,
#[serde(default)]
pub offset: i32,
#[serde(default)]
pub from_year: Option<i32>,
#[serde(default)]
pub to_year: Option<i32>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default)]
pub music_folder_id: Option<i32>,
}
impl GetAlbumListParams {
#[allow(clippy::result_large_err)]
pub fn verify(self) -> Result<Self, SubsonicResponse> {
if self.r#type == SortType::ByYear {
if self.from_year.is_none() || self.to_year.is_none() {
return Err(SubsonicResponse::new_error(
Error::RequiredParameterMissing(Some(
"Missing required parameter: fromYear or toYear".to_string(),
)),
));
}
} else if self.r#type == SortType::ByGenre && self.genre.is_none() {
return Err(SubsonicResponse::new_error(
Error::RequiredParameterMissing(Some(
"Missing required parameter: genre".to_string(),
)),
));
} else if self.size > 500 || self.size < 1 {
return Err(SubsonicResponse::new_error(Error::Generic(Some(
"size must be between 1 and 500".to_string(),
))));
}
Ok(self)
}
}
const fn default_size() -> i32 {
10
}

View file

@ -1,52 +0,0 @@
use poem::web::{Data, Query};
use crate::{
authentication::Authentication,
rest::get_album_list::GetAlbumListParams,
subsonic::{AlbumId3, SubsonicResponse},
utils::{self, middleware::DbConn},
};
#[poem::handler]
pub async fn get_album_list2(
Data(conn): Data<&DbConn>,
auth: Authentication,
Query(params): Query<GetAlbumListParams>,
) -> SubsonicResponse {
let u = utils::verify_user(conn.clone(), auth).await;
match u {
Ok(_) => {}
Err(e) => return e,
}
let params = match params.verify() {
Ok(p) => p,
Err(e) => return e,
};
if params.offset > 0 {
return SubsonicResponse::new_album_list2(Vec::new());
}
let album_list = vec![
AlbumId3 {
id: 11,
name: "Example".to_string(),
artist: Some("Example".to_string()),
song_count: 5,
duration: 100,
..Default::default()
},
AlbumId3 {
id: 12,
name: "Example 2".to_string(),
artist: Some("Example 2".to_string()),
song_count: 7,
duration: 200,
..Default::default()
},
];
SubsonicResponse::new_album_list2(album_list)
}

View file

@ -1,30 +0,0 @@
use color_eyre::Result;
use serde::{Deserialize, Serialize};
use crate::utils::middleware::DbConn;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct User {
pub id: i64,
pub name: String,
/// I hate this. It's stored in plaintext. Why?
password: String,
pub is_admin: bool,
}
impl User {
pub fn verify(&self, hashed_password: &str, salt: &str) -> bool {
let ours = md5::compute(format!("{}{}", self.password, salt));
let ours = format!("{ours:x}");
ours == hashed_password
}
}
pub async fn get_user(conn: DbConn, name: &str) -> Result<Option<User>> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE name = $1", name)
.fetch_optional(&*conn)
.await?;
Ok(user)
}

View file

@ -1,36 +0,0 @@
use tracing::error;
use crate::{
authentication::Authentication,
subsonic::{Error, SubsonicResponse},
user::{get_user, User},
};
use self::middleware::DbConn;
pub async fn verify_user(conn: DbConn, auth: Authentication) -> Result<User, SubsonicResponse> {
let user = get_user(conn, &auth.username).await;
match user {
Ok(Some(u)) => {
if u.verify(&auth.token, &auth.salt) {
Ok(u)
} else {
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
)))
}
}
Ok(None) => Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
))),
Err(e) => {
error!("Error getting user: {e}");
Err(SubsonicResponse::new_error(Error::WrongUsernameOrPassword(
None,
)))
}
}
}
pub mod middleware;

View file

@ -1,47 +0,0 @@
use std::sync::Arc;
use poem::{Endpoint, Middleware};
use sqlx::PgPool;
pub type DbConn = Arc<PgPool>;
#[derive(Debug)]
pub struct DbConnectionMiddleware {
db: PgPool,
}
impl DbConnectionMiddleware {
pub const fn new(db: PgPool) -> Self {
Self { db }
}
}
impl<E: Endpoint> Middleware<E> for DbConnectionMiddleware {
type Output = DbConnectionMwEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
DbConnectionMwEndpoint {
inner: ep,
db: self.db.clone(),
}
}
}
#[derive(Debug)]
pub struct DbConnectionMwEndpoint<E> {
inner: E,
db: PgPool,
}
#[poem::async_trait]
impl<E: Endpoint> Endpoint for DbConnectionMwEndpoint<E> {
type Output = E::Output;
async fn call(&self, mut req: poem::Request) -> Result<Self::Output, poem::Error> {
let conn = Arc::new(self.db.clone());
req.extensions_mut().insert(conn);
self.inner.call(req).await
}
}