This commit is contained in:
JMARyA 2024-10-04 12:00:33 +02:00
parent 02b9e34258
commit 7b7e1a4014
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
10 changed files with 942 additions and 270 deletions

761
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,12 @@ rocket = { version = "0.5.1", features = ["json"] }
rocket_cors = "0.6.0"
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
uuid = { version = "1.9.1", features = ["v4"] }
walkdir = "2.5.0"
mongod = { git = "https://git.hydrar.de/jmarya/mongod", features = ["cache"] }
bcrypt = "0.15.1"
data-encoding = "2.6.0"
rand = "0.8.5"
env_logger = "0.11.5"
log = "0.4.22"
chrono = "0.4.38"
chrono = { version = "0.4.38", features = ["serde"] }
uuid = { version = "1.8.0", features = ["v4", "serde"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "derive", "uuid", "chrono", "json"] }

View file

@ -5,20 +5,19 @@ services:
ports:
- "8080:8080"
depends_on:
- mongodb
- postgres
volumes:
- ./data:/data # Runtime data (optional)
- ./media:/media # Audio files
environment:
- "DB_URI=mongodb://user:pass@mongodb:27017"
- "DB=synthwrld"
- "DATABASE_URL=postgres://user:pass@postgres/synthwave"
mongodb:
image: mongo:latest
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: user
MONGO_INITDB_ROOT_PASSWORD: pass
postgres:
image: timescale/timescaledb:latest-pg16
restart: always
volumes:
- ./db:/data/db
- ./db:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=synthwave

49
migrations/0000_init.sql Normal file
View file

@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS user (
username varchar(255) NOT NULL PRIMARY KEY,
password text NOT NULL,
user_role text NOT NULL DEFAULT 'Regular' CHECK (user_role IN ('regular', 'admin'))
);
CREATE TABLE IF NOT EXISTS user_session (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
token text NOT NULL,
user varchar(255) NOT NULL,
FOREIGN KEY(user) REFERENCES user(username)
);
CREATE TABLE IF NOT EXISTS artist (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL
);
CREATE TABLE IF NOT EXISTS album (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
artist UUID
FOREIGN KEY(artist) REFERENCES artist(id)
);
CREATE TABLE IF NOT EXISTS track (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
path text NOT NULL,
title text,
date_added timestampz NOT NULL DEFAULT current_timestamp,
album UUID,
artist UUID,
meta jsonb,
FOREIGN KEY(album) REFERENCES album(id),
FOREIGN KEY(artist) REFERENCES artist(id)
);
CREATE TABLE IF NOT EXISTS events (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
time timestampz NOT NULL DEFAULT current_timestamp,
kind text CHECK (kind IN ('play', 'played', 'stop')),
user VARCHAR(255) NOT NULL,
track UUID NOT NULL,
FOREIGN KEY(user) REFERENCES user(username),
FOREIGN KEY(track) REFERENCES track(id)
);
SELECT create_hypertable('events', by_range('time'));

View file

@ -1,41 +1,33 @@
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::FromRow;
use crate::library::artist::Artist;
use mongod::ToAPI;
use crate::{get_pg, library::artist::Artist};
use super::track::Track;
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Album {
pub _id: String,
pub id: uuid::Uuid,
pub title: String,
pub artist_id: Option<Reference>,
pub artist: Option<uuid::Uuid>,
}
impl Album {
pub async fn create(title: &str, artist: Option<&str>) -> Self {
let a = Self {
_id: uuid::Uuid::new_v4().to_string(),
title: title.to_string(),
artist_id: if let Some(artist) = artist {
Some(Reference::new_raw(artist))
} else {
None
},
};
a.insert().await.unwrap();
a
sqlx::query_as("INSERT INTO album (title, artist) VALUES ($1, $2) RETURNING *")
.bind(title)
.bind(artist)
.fetch_one(get_pg!())
.await
.unwrap()
}
pub async fn get_tracks_of_album(album: &str) -> Vec<Track> {
Track::find(doc! { "album_id": album}, None, None)
pub async fn get_tracks_of_album(album: &uuid::Uuid) -> Vec<Track> {
sqlx::query_as("SELECT * FROM track WHERE album = $1")
.bind(album)
.fetch_all(get_pg!())
.await
.unwrap()
}
@ -44,7 +36,7 @@ impl Album {
///
/// This method first retrieves the list of tracks in the album, then looks for a file named "cover.{ext}" where {ext} is one of png, jpg, jpeg, avif. The first existing cover file found will be returned.
pub async fn get_cover(&self) -> Option<String> {
let track_path = Self::get_tracks_of_album(&format!("album::{}", self._id))
let track_path = Self::get_tracks_of_album(&self.id)
.await
.first()?
.path
@ -63,27 +55,17 @@ impl Album {
}
}
impl ToAPI for Album {
impl Album {
async fn api(&self) -> serde_json::Value {
json!({
"id": &self._id,
"id": &self.id,
"title": &self.title,
"artist": self.artist_id.as_ref().map(mongod::Reference::id),
"artist": self.artist,
"cover_url": if self.get_cover().await.is_some() {
Some(format!("/album/{}/cover", self._id))
Some(format!("/album/{}/cover", self.id))
} else {
None
}
})
}
}
impl Validate for Album {
async fn validate(&self) -> Result<(), String> {
if let Some(artist_id) = &self.artist_id {
assert_reference_of!(artist_id, Artist);
}
Ok(())
}
}

View file

@ -1,35 +1,31 @@
use mongod::{
derive::{Model, Referencable},
reference_of, Model, Referencable, Validate,
};
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::FromRow;
use mongod::ToAPI;
use crate::get_pg;
use super::track::Track;
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Artist {
pub _id: String,
pub id: uuid::Uuid,
pub name: String,
}
impl Artist {
pub async fn create(name: &str) -> Self {
let a = Artist {
_id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
};
a.insert().await.unwrap();
a
sqlx::query_as("INSERT INTO artist (name) VALUES ($1) RETURNING id, name")
.bind(name)
.fetch_one(get_pg!())
.await
.unwrap()
}
/// Gets the image of an artist or `None` if it can't be found.
///
/// This function gets a track from the artist. It then expects the folder structure to be `Artist/Album/Track.ext` and searches for an image file named `artist` in the artist folder.
pub async fn get_image_of(id: &str) -> Option<String> {
pub async fn get_image_of(id: &uuid::Uuid) -> Option<String> {
// todo : fix
let track_path = Track::find_one(doc! { "artist_id": reference_of!(Artist, id)})
.await?
.path;
@ -49,22 +45,16 @@ impl Artist {
}
}
impl ToAPI for Artist {
impl Artist {
async fn api(&self) -> serde_json::Value {
json!({
"id": &self._id,
"id": &self.id,
"name": &self.name,
"image": if Artist::get_image_of(self.id()).await.is_some() {
Some(format!("/artist/{}/image", self.id()))
"image": if Artist::get_image_of(&self.id).await.is_some() {
Some(format!("/artist/{}/image", self.id))
} else {
None
}
})
}
}
impl Validate for Artist {
async fn validate(&self) -> Result<(), String> {
Ok(())
}
}

View file

@ -1,25 +1,19 @@
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow;
use crate::library::user::User;
use crate::{get_pg, library::user::User};
use super::track::Track;
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Event {
pub _id: String,
pub id: uuid::Uuid,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub kind: EventKind,
pub user: Reference,
pub track: Reference,
pub timestamp: i64,
pub user: String,
pub track: uuid::Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "event_kind", rename_all = "lowercase")]
pub enum EventKind {
Play,
Played,
@ -27,36 +21,21 @@ pub enum EventKind {
}
impl Event {
pub async fn create(kind: EventKind, user: &User, track: Reference) -> Self {
let event = Self {
_id: uuid::Uuid::new_v4().to_string(),
kind: kind,
user: user.reference(),
track,
timestamp: chrono::Utc::now().timestamp(),
};
event.insert().await.unwrap();
event
pub async fn create(kind: EventKind, user: &User, track: uuid::Uuid) -> Self {
sqlx::query_as("INSERT INTO events (kind, user, track) VALUES ($1, $2, $3) RETURNING *")
.bind(kind)
.bind(&user.username)
.bind(track)
.fetch_one(get_pg!())
.await
.unwrap()
}
pub async fn get_latest_events_of(u: &User) -> Vec<Self> {
Self::find(
doc! { "user": u.reference() },
Some(300),
Some(doc! { "timestamp": -1 }),
)
sqlx::query_as("SELECT * FROM events WHERE user = $1 ORDER BY time DESC LIMIT 300")
.bind(&u.username)
.fetch_all(get_pg!())
.await
.unwrap()
}
}
impl Validate for Event {
async fn validate(&self) -> Result<(), String> {
assert_reference_of!(self.user, User);
assert_reference_of!(self.track, Track);
Ok(())
}
}

View file

@ -1,61 +1,50 @@
use std::collections::HashSet;
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::prelude::FromRow;
use std::collections::HashSet;
use crate::library::{album::Album, artist::Artist};
use mongod::ToAPI;
use crate::{
get_pg,
library::{album::Album, artist::Artist},
};
use super::{event::Event, metadata::AudioMetadata, user::User};
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Track {
pub _id: String,
pub id: uuid::Uuid,
pub path: String,
pub title: String,
pub date_added: i64,
pub album_id: Option<Reference>,
pub artist_id: Option<Reference>,
pub meta: Option<AudioMetadata>,
pub date_added: chrono::DateTime<chrono::Utc>,
pub album: Option<uuid::Uuid>,
pub artist: Option<uuid::Uuid>,
pub meta: Option<serde_json::Value>,
}
impl Track {
pub async fn create(data: &serde_json::Map<String, serde_json::Value>) {
let mut t = Self {
_id: uuid::Uuid::new_v4().to_string(),
path: data.get("path").unwrap().as_str().unwrap().to_string(),
title: data.get("title").unwrap().as_str().unwrap().to_string(),
date_added: chrono::Utc::now().timestamp(),
album_id: None,
artist_id: None,
meta: data.get("meta").map(|x| AudioMetadata(x.clone())),
};
t.insert().await.unwrap();
t.update(&serde_json::to_value(data).unwrap())
.await
.unwrap();
sqlx::query("INSERT INTO track (path, title, meta) VALUES ($1, $2, $4)")
.bind(data.get("path").unwrap().as_str().unwrap().to_string())
.bind(data.get("title").unwrap().as_str().unwrap().to_string())
.bind(data.get("meta"))
.fetch(get_pg!());
}
pub async fn get_latest_of_user(u: &User) -> Vec<Self> {
let latest_events = Event::get_latest_events_of(u).await;
let mut ids = HashSet::new();
let mut tracks = vec![];
for event in latest_events {
let track: Track = event.track.get().await;
if !ids.contains(&track._id) {
ids.insert(track._id.clone());
tracks.push(track);
if !ids.contains(&event.track) {
ids.insert(event.track.clone());
}
}
tracks
sqlx::query_as("SELECT * FROM track WHERE id = ANY($1)")
.bind(ids.into_iter().collect::<Vec<_>>())
.fetch_all(get_pg!())
.await
.unwrap()
}
/// Transcode audio to OPUS with `bitrate`
@ -100,6 +89,7 @@ impl Track {
/// Find tracks with no album or artist
pub async fn get_orphans() -> Vec<Track> {
// todo : fix
Self::find(
doc! {
"artist_id": None::<String>,
@ -113,9 +103,10 @@ impl Track {
}
}
impl ToAPI for Track {
impl Track {
async fn api(&self) -> serde_json::Value {
let (cover, album_title, album_id) = if let Some(album_ref) = &self.album_id {
// todo : fix
let (cover, album_title, album_id) = if let Some(album_ref) = &self.album {
let album = album_ref.get::<Album>().await;
(album.get_cover().await.is_some(), album.title, album._id)
@ -123,7 +114,7 @@ impl ToAPI for Track {
(false, String::new(), String::new())
};
let artist_title = if let Some(artist_ref) = &self.artist_id {
let artist_title = if let Some(artist_ref) = &self.artist {
artist_ref
.get_partial::<Artist>(json!({"name": 1}))
.await
@ -133,33 +124,19 @@ impl ToAPI for Track {
};
json!({
"id": self._id,
"id": self.id,
"title": self.title,
"track_number": self.meta.as_ref().map(super::metadata::AudioMetadata::track_number),
"track_number": self.meta.as_ref().map(|x| AudioMetadata(*x).track_number()),
"meta": serde_json::to_value(&self.meta).unwrap(),
"album_id": self.album_id.as_ref().map(|x| x.id().to_string()),
"album_id": self.album,
"album": album_title,
"cover": if cover {
Some(format!("/album/{album_id}/cover"))
} else {
None
},
"artist_id": self.artist_id.as_ref().map(|x| x.id().to_string()),
"artist_id": self.artist,
"artist": artist_title
})
}
}
impl Validate for Track {
async fn validate(&self) -> Result<(), String> {
if let Some(artist_id) = &self.artist_id {
assert_reference_of!(artist_id, Artist);
}
if let Some(album_id) = &self.artist_id {
assert_reference_of!(album_id, Artist);
}
Ok(())
}
}

View file

@ -1,15 +1,10 @@
use data_encoding::HEXUPPER;
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use mongodb::bson::doc;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::FromRow;
use mongod::ToAPI;
use crate::get_pg;
fn gen_token(token_length: usize) -> String {
let mut token_bytes = vec![0u8; token_length];
@ -19,65 +14,66 @@ fn gen_token(token_length: usize) -> String {
HEXUPPER.encode(&token_bytes)
}
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub _id: String,
pub username: String,
pub password: String,
pub role: UserRole,
pub user_role: UserRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
Regular,
Admin,
}
impl Validate for User {
async fn validate(&self) -> Result<(), String> {
Ok(())
}
}
impl User {
pub async fn create(username: &str, password: &str, role: UserRole) -> Option<Self> {
if Self::find_one_partial(doc! { "username": username }, json!({}))
pub async fn find(username: &str) -> Option<Self> {
sqlx::query_as("SELECT * FROM user WHERE username = $1")
.bind(username)
.fetch_optional(get_pg!())
.await
.is_some()
{
.unwrap()
}
pub async fn create(username: &str, password: &str, role: UserRole) -> Option<Self> {
if Self::find(username).await.is_some() {
return None;
}
let u = Self {
_id: uuid::Uuid::new_v4().to_string(),
username: username.to_string(),
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
role,
user_role: role,
};
u.insert().await.ok()?;
sqlx::query("INSERT INTO user (username, password, user_role) VALUES ($1, $2, $3)")
.bind(&u.username)
.bind(&u.password)
.bind(&u.user_role)
.fetch(get_pg!());
Some(u)
}
pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> {
let u = Self::find_one(doc! { "username": username }).await?;
let u = Self::find(username).await?;
if !u.verify_pw(password) {
return None;
}
Some((u.session().await, u.role))
Some((u.session().await, u.user_role))
}
/// Change the password of a `User`
pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> {
if self.verify_pw(old) {
self.change()
.password(bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap())
.update()
.await
.map_err(|_| ())?;
sqlx::query("UPDATE user SET password = $1 WHERE username = $2;")
.bind(bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap())
.bind(&self.username)
.fetch(get_pg!());
return Ok(());
}
@ -86,15 +82,14 @@ impl User {
}
pub async fn session(&self) -> Session {
let s = Session {
_id: uuid::Uuid::new_v4().to_string(),
token: gen_token(60),
user: self.reference(),
};
s.insert().await.unwrap();
s
sqlx::query_as(
"INSERT INTO user_session (token, user) VALUES ($1, $2) RETURNING id, token, user",
)
.bind(gen_token(64))
.bind(&self.username)
.fetch_one(get_pg!())
.await
.unwrap()
}
pub const fn is_admin(&self) -> bool {
@ -106,26 +101,18 @@ impl User {
}
}
impl ToAPI for User {
impl User {
async fn api(&self) -> serde_json::Value {
json!({
"username": self.username,
"role": self.role
"role": self.user_role
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub _id: String,
pub id: uuid::Uuid,
pub token: String,
pub user: Reference,
}
impl Validate for Session {
async fn validate(&self) -> Result<(), String> {
assert_reference_of!(self.user, User);
Ok(())
}
pub user: String,
}

View file

@ -5,11 +5,30 @@ mod library;
mod route;
use library::user::{User, UserRole};
use mongod::Model;
use mongodb::bson::doc;
use rocket::routes;
use rocket::tokio::sync::OnceCell;
use rocket::{http::Method, launch};
pub static PG: OnceCell<sqlx::PgPool> = OnceCell::const_new();
#[macro_export]
macro_rules! get_pg {
() => {
if let Some(client) = $crate::PG.get() {
client
} else {
let client = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
$crate::PG.set(client).unwrap();
$crate::PG.get().unwrap()
}
};
}
#[launch]
async fn rocket() -> _ {
env_logger::init();
@ -27,16 +46,17 @@ async fn rocket() -> _ {
.to_cors()
.expect("error creating CORS options");
let pg = get_pg!();
sqlx::migrate!("./migrations").run(pg).await.unwrap();
let lib = Libary::new("./media".into());
let cache = cache::RouteCache::new();
lib.rescan(&cache).await;
// create initial admin user
if User::find(doc! { "username": "admin" }, None, None)
.await
.is_none()
{
if User::find("admin").await.is_none() {
User::create("admin", "admin", UserRole::Admin).await;
}