This commit is contained in:
JMARyA 2024-07-24 11:07:24 +02:00
parent 0d3df6bb64
commit dcf546fa9c
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
18 changed files with 3463 additions and 1 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/db

2714
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "synthwave"
version = "0.1.0"
edition = "2021"
[dependencies]
mongodb = "2.8.2"
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" }
bcrypt = "0.15.1"
data-encoding = "2.6.0"
rand = "0.8.5"

View file

@ -1,2 +1,3 @@
# synth.universe # synthwave
synthwave music server

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
version: '3'
services:
# synthwave:
# build: .
# ports:
# - "8080:8080"
# depends_on:
# - mongodb
# environment:
# - "DB_URI=mongodb://user:pass@mongodb:27017"
# - "DB=synthwrld"
mongodb:
image: mongo:latest
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: user
MONGO_INITDB_ROOT_PASSWORD: pass
volumes:
- ./db:/data/db

35
src/extract_metadata.py Normal file
View file

@ -0,0 +1,35 @@
import sys
from mutagen import File
import json
def print_metadata(file_path):
try:
audio = File(file_path)
meta = {}
if audio is None:
print(json.dumps({"error": "unsupported file"}))
return
for key, value in audio.items():
if type(value) is list and len(value) == 1:
value = value[0]
meta[key] = value
if audio.info:
meta["info"] = {}
for key, value in vars(audio.info).items():
meta["info"][key] = value
print(json.dumps(meta))
except Exception as e:
print(json.dumps({"error": str(e)}))
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python script.py <path_to_audio_file>")
sys.exit(1)
file_path = sys.argv[1]
print_metadata(file_path)

41
src/library/album.rs Normal file
View file

@ -0,0 +1,41 @@
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use serde::{Deserialize, Serialize};
use crate::library::artist::Artist;
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
pub struct Album {
pub _id: String,
pub title: String,
pub artist_id: Option<Reference>,
}
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).await.unwrap())
} else {
None
},
};
a.insert().await.unwrap();
a
}
}
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(())
}
}

28
src/library/artist.rs Normal file
View file

@ -0,0 +1,28 @@
use mongod::{
derive::{Model, Referencable},
Model, Validate,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
pub struct Artist {
pub _id: String,
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
}
}
impl Validate for Artist {
async fn validate(&self) -> Result<(), String> {
Ok(())
}
}

57
src/library/metadata.rs Normal file
View file

@ -0,0 +1,57 @@
use std::ops::Deref;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AudioMetadata(pub serde_json::Value);
impl Into<mongodb::bson::Bson> for AudioMetadata {
fn into(self) -> mongodb::bson::Bson {
mongodb::bson::to_bson(&self.0).unwrap()
}
}
impl AudioMetadata {
fn get_key(&self, key: &str) -> Option<&str> {
Some(self.0.as_object()?.get(key)?.as_str()?)
}
pub fn title(&self) -> Option<&str> {
self.get_key("title")
}
pub fn artist(&self) -> Option<&str> {
self.get_key("artist")
}
pub fn album(&self) -> Option<&str> {
self.get_key("album")
}
pub fn track_number(&self) -> Option<usize> {
self.get_key("tracknumber").map(|x| x.parse().ok())?
}
}
pub fn get_metadata(file_path: &str) -> Option<AudioMetadata> {
Some(AudioMetadata(get_metadata_json(file_path)?))
}
pub fn get_metadata_json(file_path: &str) -> Option<serde_json::Value> {
let output = std::process::Command::new("python3")
.arg("src/extract_metadata.py")
.arg(file_path)
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).ok()?
}
impl Deref for AudioMetadata {
type Target = serde_json::Value;
fn deref(&self) -> &Self::Target {
&self.0
}
}

152
src/library/mod.rs Normal file
View file

@ -0,0 +1,152 @@
use std::path::{Path, PathBuf};
use album::Album;
use artist::Artist;
use mongod::{Model, Referencable, Reference};
use mongodb::bson::doc;
use serde_json::json;
use track::Track;
use walkdir::WalkDir;
pub mod album;
pub mod artist;
pub mod metadata;
pub mod track;
pub mod user;
/// Checks if a file has a music file extension
fn is_music_file(path: &Path) -> bool {
if let Some(extension) = path.extension() {
match extension.to_str().unwrap_or("").to_lowercase().as_str() {
"mp3" | "flac" | "wav" | "aac" | "ogg" | "m4a" | "opus" => return true,
_ => return false,
}
}
false
}
pub struct Libary {
root_dir: PathBuf,
}
impl Libary {
pub async fn new(root_dir: PathBuf) -> Self {
Self { root_dir }
}
pub async fn find_or_create_artist(&self, artist: &str) -> Reference {
if let Some(artist) = Artist::find_one(doc! { "name": artist }).await {
artist.reference()
} else {
Artist::create(artist).await.reference()
}
}
pub async fn find_or_create_album(&self, album: &str, artist_id: Option<&str>) -> Reference {
if let Some(album) = Album::find_one(doc! { "title": album, "artist_id": artist_id}).await {
album.reference()
} else {
Album::create(album, artist_id).await.reference()
}
}
pub async fn add_path_to_library(&self, path: &str) {
// search for path already present
if let Some(_) = Track::find_one_partial(doc! { "path": path }, &json!({})).await {
return;
}
// add track to library
let metadata = metadata::get_metadata(path);
let mut entry = json!({
"_id": uuid::Uuid::new_v4().to_string(),
"path": path,
});
if let Some(meta) = &metadata {
if let Some(artist) = meta.artist() {
let artist_id = self.find_or_create_artist(artist).await;
entry
.as_object_mut()
.unwrap()
.insert("artist_id".into(), artist_id.into());
}
if let Some(album) = meta.album() {
let album_id = self
.find_or_create_album(
album,
entry
.as_object()
.unwrap()
.get("artist_id")
.map(|x| x.as_str().unwrap()),
)
.await;
entry
.as_object_mut()
.unwrap()
.insert("album_id".into(), album_id.into());
}
if let Some(title) = meta.title() {
entry
.as_object_mut()
.unwrap()
.insert("title".into(), title.into());
}
}
if let Some(metadata) = metadata {
entry
.as_object_mut()
.unwrap()
.insert("meta".into(), metadata.0);
}
Track::create(&entry.as_object().unwrap()).await;
}
pub async fn get_artists(&self) -> Vec<Artist> {
Artist::find(doc! {}).await.unwrap()
}
pub async fn get_artist_by_id(&self, id: &str) -> Option<Artist> {
Artist::get(id).await
}
pub async fn get_albums_by_artist(&self, artist: &str) -> Vec<Album> {
let artist = format!("artist::{artist}");
Album::find(doc! { "artist_id": artist}).await.unwrap()
}
pub async fn get_album_by_id(&self, album: &str) -> Option<Album> {
Album::get(album).await
}
pub async fn get_tracks_of_album(&self, album: &str) -> Vec<Track> {
Track::find(doc! { "album_id": album}).await.unwrap()
}
pub async fn get_track_by_id(&self, track_id: &str) -> Option<Track> {
Track::get(track_id).await
}
pub async fn rescan(&self) {
for entry in WalkDir::new(self.root_dir.clone())
.follow_links(true)
.into_iter()
.take(30) // todo : remove
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() && is_music_file(path) {
let path = path.to_string_lossy().to_string();
println!("Found {path}");
self.add_path_to_library(&path).await;
}
}
}
}

51
src/library/track.rs Normal file
View file

@ -0,0 +1,51 @@
use mongod::{
assert_reference_of,
derive::{Model, Referencable},
Model, Referencable, Reference, Validate,
};
use serde::{Deserialize, Serialize};
use crate::library::artist::Artist;
use super::metadata::AudioMetadata;
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
pub struct Track {
pub _id: String,
pub path: String,
pub title: String,
pub album_id: Option<Reference>,
pub artist_id: Option<Reference>,
pub meta: Option<AudioMetadata>,
}
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(),
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();
}
}
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(())
}
}

100
src/library/user.rs Normal file
View file

@ -0,0 +1,100 @@
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;
fn gen_token(token_length: usize) -> String {
let mut token_bytes = vec![0u8; token_length];
rand::thread_rng().fill_bytes(&mut token_bytes);
HEXUPPER.encode(&token_bytes)
}
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
pub struct User {
pub _id: String,
pub username: String,
pub password: String,
pub role: UserRole
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
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 User::find_one_partial(doc! { "username": username }, &json!({})).await.is_some() {
return None;
}
let u = User{
_id: uuid::Uuid::new_v4().to_string(),
username: username.to_string(),
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
role
};
u.insert().await.unwrap();
Some(u)
}
pub async fn login(username: &str, password: &str) -> Option<Session> {
let u = User::find_one(doc! { "username": username }).await?;
if !u.verify_pw(password) {
return None;
}
Some(u.session().await)
}
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
}
pub fn is_admin(&self) -> bool {
matches!(self.role, UserRole::Admin)
}
pub fn verify_pw(&self, password: &str) -> bool {
bcrypt::verify(password, &self.password).unwrap()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Model, Referencable)]
pub struct Session {
pub _id: String,
pub token: String,
pub user: Reference
}
impl Validate for Session {
async fn validate(&self) -> Result<(), String> {
assert_reference_of!(self.user, User);
Ok(())
}
}

43
src/main.rs Normal file
View file

@ -0,0 +1,43 @@
use library::Libary;
mod library;
mod route;
use rocket::routes;
use rocket::{http::Method, launch};
#[launch]
async fn rocket() -> _ {
let cors = rocket_cors::CorsOptions {
allowed_origins: rocket_cors::AllowedOrigins::all(),
allowed_methods: vec![Method::Get, Method::Post, Method::Options]
.into_iter()
.map(From::from)
.collect(),
allowed_headers: rocket_cors::AllowedHeaders::all(),
allow_credentials: true,
..Default::default()
}
.to_cors()
.expect("error creating CORS options");
let lib = Libary::new("/Users/angelo/Downloads/Music".into()).await;
lib.rescan().await;
rocket::build()
.mount(
"/",
routes![
route::artist::artists_route,
route::artist::artist_route,
route::album::albums_route,
route::album::album_route,
route::track::track_route,
route::track::track_audio_route,
route::album::album_cover_route
],
)
.manage(lib)
.attach(cors)
}

83
src/route/album.rs Normal file
View file

@ -0,0 +1,83 @@
use std::cmp::Ordering;
use mongodb::bson::doc;
use rocket::*;
use serde_json::json;
use rocket::fs::NamedFile;
use mongod::Referencable;
use super::FallibleApiResponse;
use super::api_error;
use crate::library::Libary;
#[get("/artist/<artist_id>/albums")]
pub async fn albums_route(artist_id: &str, lib: &State<Libary>) -> FallibleApiResponse {
let albums = lib.get_albums_by_artist(artist_id).await;
Ok(json!({
"artist": artist_id,
"albums": &albums
}))
}
fn sort_by_tracknumber(a: &serde_json::Value, b: &serde_json::Value) -> Ordering {
a.get("tracknumber")
.unwrap()
.as_i64()
.unwrap()
.cmp(&b.get("tracknumber").unwrap().as_i64().unwrap())
}
#[get("/album/<album_id>/cover")]
pub async fn album_cover_route(album_id: &str, lib: &State<Libary>) -> Option<NamedFile> {
let album = lib.get_album_by_id(album_id).await?;
let track_path = lib
.get_tracks_of_album(&format!("album::{}", album._id))
.await
.first()?
.path
.clone();
let track_path = std::path::Path::new(&track_path);
for ext in ["png", "jpg", "jpeg", "avif"] {
let cover_file = track_path.parent()?.join(format!("cover.{ext}"));
if cover_file.exists() {
return NamedFile::open(cover_file).await.ok();
}
}
None
}
#[get("/album/<album_id>")]
pub async fn album_route(album_id: &str, lib: &State<Libary>) -> FallibleApiResponse {
let album = lib
.get_album_by_id(album_id)
.await
.ok_or_else(|| api_error("No album with that ID found"))?;
let mut tracks = lib
.get_tracks_of_album(&format!("album::{}", album._id))
.await
.into_iter()
.map(|x| {
json!({
"id": x.id(),
"title": x.title,
"tracknumber": x.meta.map(|x| x.track_number())
})
})
.collect::<Vec<_>>();
tracks.sort_by(sort_by_tracknumber);
let mut album = serde_json::to_value(album).unwrap();
album
.as_object_mut()
.unwrap()
.insert("tracks".into(), tracks.into());
Ok(album)
}

23
src/route/artist.rs Normal file
View file

@ -0,0 +1,23 @@
use mongodb::bson::doc;
use rocket::*;
use super::FallibleApiResponse;
use super::api_error;
use crate::library::Libary;
/// Get all artists
#[get("/artists")]
pub async fn artists_route(lib: &State<Libary>) -> FallibleApiResponse {
let artists = lib.get_artists().await;
Ok(serde_json::to_value(&artists).unwrap())
}
#[get("/artist/<id>")]
pub async fn artist_route(id: &str, lib: &State<Libary>) -> FallibleApiResponse {
Ok(serde_json::to_value(
&lib.get_artist_by_id(id)
.await
.ok_or_else(|| api_error("No artist with that ID found"))?,
)
.unwrap())
}

17
src/route/mod.rs Normal file
View file

@ -0,0 +1,17 @@
use rocket::response::status::BadRequest;
use serde_json::json;
pub mod artist;
pub mod album;
pub mod track;
pub mod user;
type ApiError = BadRequest<serde_json::Value>;
type FallibleApiResponse = Result<serde_json::Value, ApiError>;
pub fn api_error(msg: &str) -> ApiError {
BadRequest(json!({
"error": msg
}))
}

25
src/route/track.rs Normal file
View file

@ -0,0 +1,25 @@
use rocket::*;
use fs::NamedFile;
use mongodb::bson::doc;
use super::FallibleApiResponse;
use super::api_error;
use crate::library::Libary;
#[get("/track/<track_id>")]
pub async fn track_route(track_id: &str, lib: &State<Libary>) -> FallibleApiResponse {
Ok(serde_json::to_value(
&lib.get_track_by_id(track_id)
.await
.ok_or_else(|| api_error("No track with that ID found"))?,
)
.unwrap())
}
#[get("/track/<track_id>/audio")]
pub async fn track_audio_route(track_id: &str, lib: &State<Libary>) -> Option<NamedFile> {
let track = lib.get_track_by_id(track_id).await?;
NamedFile::open(std::path::Path::new(&track.path))
.await
.ok()
}

51
src/route/user.rs Normal file
View file

@ -0,0 +1,51 @@
use rocket::http::Status;
use mongod::Model;
use mongodb::bson::doc;
use rocket::outcome::Outcome;
use rocket::post;
use rocket::request::FromRequest;
use rocket::Request;
use serde_json::json;
use serde::Deserialize;
use rocket::serde::json::Json;
use crate::library::user::Session;
use crate::library::user::User;
use super::FallibleApiResponse;
use super::api_error;
#[rocket::async_trait]
impl<'r> FromRequest<'r> for User {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
match request.headers().get_one("token") {
Some(key) => {
if let Some(session) = Session::find_one(doc! { "token": key} ).await {
let user = session.user.get().await;
Outcome::Success(user)
} else {
Outcome::Error((Status::Unauthorized, ()))
}
},
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
#[derive(Deserialize)]
pub struct LoginData {
pub username: String,
pub password: String
}
#[post("/login", data = "<login>")]
pub async fn login_route(login: Json<LoginData>) -> FallibleApiResponse {
let ses = User::login(&login.username, &login.password).await.ok_or_else(|| api_error("Login failed"))?;
Ok(json!({
"token": ses.token
}))
}