feat: add basic HTTP, websocket functionality
- add ntex and serde as requirements - add HTTP server with index/ and ws/ endpoints - add websocket types and systems, basic Ping and GetTracks endpoints - add serialize/deserialize support to models::Tracks - add handle and get_tracks method to library - clean up types.rs
This commit is contained in:
@@ -8,3 +8,6 @@ diesel = { version = "2.3.14", features = ["sqlite", "returning_clauses_for_sqli
|
|||||||
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
|
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
lofty = "0.25.4"
|
lofty = "0.25.4"
|
||||||
|
ntex = { version = "4.4.0", features = ["tokio"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
serde_json = "1.0.154"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
fs::{self, File},
|
fs::{self, File},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use diesel::{prelude::*, sqlite::SqliteConnection};
|
use diesel::{prelude::*, sqlite::SqliteConnection};
|
||||||
@@ -25,6 +26,13 @@ impl Library {
|
|||||||
Self { conn }
|
Self { conn }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: implement similar limit/offset/count behavior
|
||||||
|
pub fn get_tracks(&mut self) -> QueryResult<Vec<Track>> {
|
||||||
|
schema::tracks::table
|
||||||
|
.order(schema::tracks::id.asc())
|
||||||
|
.load::<Track>(&mut self.conn)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print(&mut self) -> QueryResult<()> {
|
pub fn print(&mut self) -> QueryResult<()> {
|
||||||
let artists = schema::artists::table
|
let artists = schema::artists::table
|
||||||
.order(schema::artists::id.asc())
|
.order(schema::artists::id.asc())
|
||||||
@@ -46,6 +54,20 @@ impl Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Handle(Arc<Mutex<Library>>);
|
||||||
|
|
||||||
|
impl Handle {
|
||||||
|
pub fn new(library: Library) -> Self {
|
||||||
|
Self(Arc::new(Mutex::new(library)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_library<R>(&self, f: impl FnOnce(&mut Library) -> R) -> R {
|
||||||
|
let mut library = self.0.lock().expect("Library handle poisoned!");
|
||||||
|
f(&mut library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Importer;
|
pub struct Importer;
|
||||||
|
|
||||||
impl Importer {
|
impl Importer {
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use std::env;
|
|||||||
|
|
||||||
mod library;
|
mod library;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod net;
|
||||||
mod schema;
|
mod schema;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
use library::{Importer, Library};
|
use library::{Handle, Importer, Library};
|
||||||
|
|
||||||
fn open_db_file() -> SqliteConnection {
|
fn open_db_file() -> SqliteConnection {
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL missing");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL missing");
|
||||||
@@ -47,4 +48,8 @@ fn main() {
|
|||||||
.expect("Error importing files");
|
.expect("Error importing files");
|
||||||
|
|
||||||
library_instance.print().expect("Error printing library");
|
library_instance.print().expect("Error printing library");
|
||||||
|
|
||||||
|
let library_handle = Handle::new(library_instance);
|
||||||
|
|
||||||
|
net::http::start(library_handle).expect("HTTP server failed to start");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::schema::{artists, collections, tracks};
|
use crate::schema::{artists, collections, tracks};
|
||||||
|
|
||||||
@@ -22,7 +23,10 @@ pub struct Collection {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Selectable, Identifiable, Associations)]
|
// TODO: make this more ergonomic (nested artist object instead of artist ID, etc)
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
#[diesel(table_name = tracks)]
|
#[diesel(table_name = tracks)]
|
||||||
#[diesel(belongs_to(Artist, foreign_key = artist_id))]
|
#[diesel(belongs_to(Artist, foreign_key = artist_id))]
|
||||||
#[diesel(belongs_to(Collection, foreign_key = collection_id))]
|
#[diesel(belongs_to(Collection, foreign_key = collection_id))]
|
||||||
|
|||||||
250
src/net.rs
Normal file
250
src/net.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
pub mod http {
|
||||||
|
use std::{
|
||||||
|
env, io,
|
||||||
|
sync::{self, atomic},
|
||||||
|
};
|
||||||
|
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use ntex::{SharedCfg, server, web};
|
||||||
|
|
||||||
|
use crate::library;
|
||||||
|
use crate::net::ws;
|
||||||
|
|
||||||
|
// This is per-worker state
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
worker_id: usize,
|
||||||
|
library_handle: library::Handle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl web::State for AppState {
|
||||||
|
type Error = web::DefaultError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is global application state
|
||||||
|
struct AppBuilder {
|
||||||
|
next_worker_id: atomic::AtomicUsize,
|
||||||
|
library_handle: library::Handle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppBuilder {
|
||||||
|
// This runs when creating the application, so we pass the database handle into it (which will be cloned later)
|
||||||
|
fn new(library_handle: library::Handle) -> Self {
|
||||||
|
Self {
|
||||||
|
next_worker_id: atomic::AtomicUsize::new(0),
|
||||||
|
library_handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl server::ServerAppConfig for AppBuilder {
|
||||||
|
type State = AppState;
|
||||||
|
|
||||||
|
// This runs whenever a worker is created, and uses the global state created on application creation
|
||||||
|
async fn create(&self) -> io::Result<Self::State> {
|
||||||
|
let worker_id = self.next_worker_id.fetch_add(1, atomic::Ordering::Relaxed);
|
||||||
|
println!("Creating state for worker {worker_id}");
|
||||||
|
|
||||||
|
Ok(AppState {
|
||||||
|
worker_id,
|
||||||
|
library_handle: self.library_handle.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> web::HttpResponse {
|
||||||
|
println!("received request");
|
||||||
|
|
||||||
|
// TODO: add basic stats like database size, taken from a counter updated upon import task");
|
||||||
|
web::HttpResponse::Ok().body("<h1>tuneparty is running!</h1>")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn websocket(state: &AppState, _: (), req: web::HttpRequest) -> web::HttpResponse {
|
||||||
|
println!("starting websocket connection");
|
||||||
|
|
||||||
|
let cloned_handle = state.library_handle.clone();
|
||||||
|
|
||||||
|
let result = web::ws::start(&req, None, async move |frame: web::ws::Frame| {
|
||||||
|
let message = match frame {
|
||||||
|
web::ws::Frame::Text(text) => ws::handle_message(&cloned_handle, text),
|
||||||
|
web::ws::Frame::Binary(data) => Some(web::ws::Message::Binary(data)),
|
||||||
|
web::ws::Frame::Close(_) => Some(web::ws::Message::Close(Some(
|
||||||
|
web::ws::CloseCode::Normal.into(),
|
||||||
|
))),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok::<_, std::io::Error>(message)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("WebSocket connection failed: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
web::HttpResponse::Ok().body("websocket connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ntex::main]
|
||||||
|
pub async fn start(library_handle: library::Handle) -> std::io::Result<()> {
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
let address = env::var("HTTP_ADDRESS").unwrap_or_else(|_| {
|
||||||
|
eprintln!("HTTP_ADDRESS not set; using default 127.0.0.1");
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
});
|
||||||
|
let port: u16 = env::var("HTTP_PORT")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
eprintln!("HTTP_PORT not set; using default 8080");
|
||||||
|
"8080".to_string()
|
||||||
|
})
|
||||||
|
.parse()
|
||||||
|
.expect("port must be a number between 1 and 65536");
|
||||||
|
|
||||||
|
let cfg = AppBuilder::new(library_handle);
|
||||||
|
|
||||||
|
web::server_with_config(cfg, async |_: &AppState| {
|
||||||
|
web::App::new()
|
||||||
|
.service(web::resource("/").to(index))
|
||||||
|
.service(web::resource("/ws").to_with_state(websocket))
|
||||||
|
})
|
||||||
|
.bind((address, port), ntex::SharedCfg::new("STATE"))?
|
||||||
|
.workers(2)
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod ws {
|
||||||
|
use ntex::{util::Bytes, web, web::ws::Message as WsMessage};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{library, models};
|
||||||
|
|
||||||
|
// TODO: custom deserializer that tries the id if possible in order to send potential errors back to the correct device
|
||||||
|
// taking notes from https://github.com/clangen/musikcube/wiki/remote-api-documentation
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Message {
|
||||||
|
pub id: i32,
|
||||||
|
pub direction: MessageDirection,
|
||||||
|
pub params: MessageParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "msg_type")]
|
||||||
|
enum MessageParams {
|
||||||
|
Error(ErrorParams),
|
||||||
|
|
||||||
|
GetTracks(GetTracks),
|
||||||
|
GetTracksResponse(GetTracksResponse),
|
||||||
|
|
||||||
|
Ping(()),
|
||||||
|
Pong(()),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
enum MessageDirection {
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
Broadcast,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct ErrorParams {
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// doesn't take any params for now and queries all tracks period
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GetTracks {}
|
||||||
|
|
||||||
|
// TODO: implement similar limit/offset/count behavior
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GetTracksResponse {
|
||||||
|
items: Vec<models::Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_message(library_handle: &library::Handle, msg_raw: Bytes) -> Option<WsMessage> {
|
||||||
|
print!("received websocket message! ");
|
||||||
|
|
||||||
|
let msg = match decode_message(msg_raw) {
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(e) => {
|
||||||
|
println!("couldn't decode it :(\nwhy: {:?}", e);
|
||||||
|
return Some(web::ws::Message::Text("received malformed data".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
print!("message has id {}\n", &msg.id);
|
||||||
|
|
||||||
|
// this only handles incoming message types
|
||||||
|
match msg.params {
|
||||||
|
MessageParams::Ping(_) => generate_pong_response(&msg),
|
||||||
|
MessageParams::GetTracks(ref p) => get_tracks(library_handle, &msg, &p),
|
||||||
|
_ => generate_error_response("invalid msg_type", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
//Some(web::ws::Message::Text("ok".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_message(msg_raw: Bytes) -> Result<Message, serde_json::Error> {
|
||||||
|
let msg_str = String::from_utf8_lossy(&msg_raw);
|
||||||
|
|
||||||
|
serde_json::from_str::<Message>(&msg_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_pong_response(msg: &Message) -> Option<WsMessage> {
|
||||||
|
let msg = Message {
|
||||||
|
id: msg.id,
|
||||||
|
direction: MessageDirection::Response,
|
||||||
|
params: MessageParams::Pong(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_string(&msg)
|
||||||
|
.expect("unable to serialize pong message for whatever reason...");
|
||||||
|
|
||||||
|
Some(web::ws::Message::Text(payload.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make error messages more flexible/descriptive
|
||||||
|
fn generate_error_response(error_text: &str, id: Option<i32>) -> Option<WsMessage> {
|
||||||
|
let id = id.unwrap_or(-1);
|
||||||
|
|
||||||
|
let msg = Message {
|
||||||
|
id,
|
||||||
|
direction: MessageDirection::Response,
|
||||||
|
params: MessageParams::Error(ErrorParams {
|
||||||
|
reason: error_text.into(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_string(&msg)
|
||||||
|
.expect("unable to serialize error message for whatever reason...");
|
||||||
|
|
||||||
|
Some(web::ws::Message::Text(payload.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_tracks(
|
||||||
|
library_handle: &library::Handle,
|
||||||
|
msg: &Message,
|
||||||
|
params: &GetTracks,
|
||||||
|
) -> Option<WsMessage> {
|
||||||
|
// TODO: perhaps more descriptive errors
|
||||||
|
let tracks = library_handle.with_library(|lib| lib.get_tracks());
|
||||||
|
|
||||||
|
let Ok(tracks) = tracks else {
|
||||||
|
return generate_error_response("error getting tracks from database", Some(msg.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = Message {
|
||||||
|
id: msg.id,
|
||||||
|
direction: MessageDirection::Response,
|
||||||
|
params: MessageParams::GetTracksResponse(GetTracksResponse { items: tracks }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_string(&msg)
|
||||||
|
.expect("unable to serialize error message for whatever reason...");
|
||||||
|
|
||||||
|
Some(web::ws::Message::Text(payload.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/types.rs
12
src/types.rs
@@ -5,18 +5,6 @@ pub mod library {
|
|||||||
|
|
||||||
use crate::types::errors;
|
use crate::types::errors;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Artist {
|
|
||||||
id: u32,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Collection {
|
|
||||||
id: u32,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ScannedTrackMetadata {
|
pub struct ScannedTrackMetadata {
|
||||||
pub artist: Option<String>,
|
pub artist: Option<String>,
|
||||||
|
|||||||
Reference in New Issue
Block a user