diff --git a/src/library.rs b/src/library.rs index 88e12bb..ad03106 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,11 +1,12 @@ use std::{ error::Error, - fs::{self, File}, - path::{Path, PathBuf}, + fs, + path::Path, sync::{Arc, Mutex}, }; -use diesel::{prelude::*, result::Error::NotFound, sqlite::SqliteConnection}; +use diesel::{prelude::*, sqlite::SqliteConnection}; + use lofty::{prelude::*, read_from}; use crate::{ @@ -26,35 +27,14 @@ impl Library { Self { conn } } - // TODO: implement similar limit/offset/count behavior - pub fn get_tracks(&mut self) -> QueryResult> { - schema::tracks::table - .order(schema::tracks::id.asc()) - .load::(&mut self.conn) - } - - pub fn get_track_by_id(&mut self, id: u32) -> QueryResult { - let Ok(id) = i32::try_from(id) else { - return Err(NotFound); - }; - - schema::tracks::table - .find(i32::from(id)) - .first(&mut self.conn) + pub fn conn(&mut self) -> &mut SqliteConnection { + &mut self.conn } pub fn print(&mut self) -> QueryResult<()> { - let artists = schema::artists::table - .order(schema::artists::id.asc()) - .load::(&mut self.conn)?; - - let collections = schema::collections::table - .order(schema::collections::id.asc()) - .load::(&mut self.conn)?; - - let tracks = schema::tracks::table - .order(schema::tracks::id.asc()) - .load::(&mut self.conn)?; + let artists = Artist::all().load(&mut self.conn)?; + let collections = Collection::all().load(&mut self.conn)?; + let tracks = Track::all().load(&mut self.conn)?; println!("Artists:\n{artists:#?}"); println!("Collections:\n{collections:#?}"); @@ -131,7 +111,7 @@ impl Importer { } fn read_metadata(path: &Path) -> Result { - let mut file = File::open(path).map_err(|source| TrackScanError::FileOpen { + let mut file = fs::File::open(path).map_err(|source| TrackScanError::FileOpen { path: path.to_path_buf(), source, })?; diff --git a/src/main.rs b/src/main.rs index 5b665e0..df546b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,4 @@ -use std::{ - fs::metadata, - path::{Path, PathBuf}, - string, -}; +use std::path::Path; use diesel::prelude::*; use dotenvy::dotenv; diff --git a/src/models.rs b/src/models.rs index 4fbe310..3d77132 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,7 +1,13 @@ use diesel::prelude::*; +use diesel::dsl::{AsSelect, Filter, Select, Eq, auto_type}; +use diesel::sqlite::Sqlite; +use futures::task::waker; use serde::{Deserialize, Serialize}; use crate::schema::{artists, collections, tracks}; +use crate::library::Library; + +type All = Select>; #[derive(Debug, Clone, Queryable, Selectable, Identifiable)] #[diesel(table_name = artists)] @@ -12,6 +18,26 @@ pub struct Artist { pub display_name: String, } +impl Artist { + pub fn all() -> All { + artists::table.select(Self::as_select()) + } + + #[auto_type(no_type_alias)] + pub fn by_id(id: i32) -> _ { + let all: All = Self::all(); + let filter = artists::id.eq(id); + all.filter(filter) + } + + #[auto_type(no_type_alias)] + pub fn by_original_name(name: &str) -> _ { + let all: All = Self::all(); + let filter = artists::original_name.eq(name); + all.filter(filter) + } +} + #[derive(Debug, Clone, Queryable, Selectable, Identifiable)] #[diesel(table_name = collections)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -23,7 +49,26 @@ pub struct Collection { pub display_name: String, } -// TODO: make this more ergonomic (nested artist object instead of artist ID, etc) +impl Collection { + pub fn all() -> All { + collections::table.select(Self::as_select()) + } + + #[auto_type(no_type_alias)] + pub fn by_id(id: i32) -> _ { + let all: All = Self::all(); + let filter = collections::id.eq(id); + all.filter(filter) + } + + #[auto_type(no_type_alias)] + pub fn by_original_name(name: &str) -> _ { + let all: All = Self::all(); + let filter = collections::original_name.eq(name); + all.filter(filter) + } +} + #[derive( Debug, Clone, Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, )] @@ -44,6 +89,58 @@ pub struct Track { pub length_sec: i32, } +impl Track { + pub fn all() -> All { + tracks::table.select(Self::as_select()) + } + + #[auto_type(no_type_alias)] + pub fn by_id(id: i32) -> _ { + let all: All = Self::all(); + let filter = tracks::id.eq(id); + all.filter(filter) + } + + #[auto_type(no_type_alias)] + pub fn by_original_title(title: &str) -> _ { + let all: All = Self::all(); + let filter = tracks::original_title.eq(title); + all.filter(filter) + } +} + + +#[derive(Debug, Serialize)] +pub struct DetailedArtist { + pub id: i32, + pub original_name: String, + pub display_name: String, +} + +#[derive(Debug, Serialize)] +pub struct DetailedCollection<'a> { + pub id: i32, + pub artist: Option<&'a DetailedArtist>, + pub collection_type: String, + pub original_name: String, + pub display_name: String, +} + +#[derive(Debug, Serialize)] +pub struct DetailedTrack<'a> { + pub id: i32, + pub file_path: String, + pub artist: Option<&'a DetailedArtist>, + // a collection could be under another artist (e.g. mixtapes) + pub collection: Option<&'a DetailedCollection<'a>>, + pub original_title: Option, + pub display_title: Option, + pub track_number: Option, + pub genre: Option, + pub track_datetime: Option, + pub length_sec: i32, +} + #[derive(Debug, Clone, Insertable)] #[diesel(table_name = artists)] pub struct NewArtist<'a> { diff --git a/src/net.rs b/src/net.rs index 7f84dcc..2cc753e 100644 --- a/src/net.rs +++ b/src/net.rs @@ -1,22 +1,17 @@ pub mod http { use std::{ - env, fs, - io::{self, Read}, - sync::{self, atomic}, + env, fs, io, + sync::atomic::{AtomicUsize, Ordering}, }; + use diesel::RunQueryDsl; use dotenvy::dotenv; - use futures::{future, stream, task}; - use ntex::{ - SharedCfg, server, - util::{self, Buf}, - web, - }; + use ntex::{server, web}; use serde::Deserialize; - use crate::library; use crate::net::ws; use crate::stream::AudioFileStream; + use crate::{library, models, schema}; // This is per-worker state #[derive(Clone)] @@ -31,7 +26,7 @@ pub mod http { // This is global application state struct AppBuilder { - next_worker_id: atomic::AtomicUsize, + next_worker_id: AtomicUsize, library_handle: library::Handle, } @@ -39,7 +34,7 @@ pub mod http { // 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), + next_worker_id: AtomicUsize::new(0), library_handle, } } @@ -55,7 +50,7 @@ pub mod http { // This runs whenever a worker is created, and uses the global state created on application creation async fn create(&self) -> io::Result { - let worker_id = self.next_worker_id.fetch_add(1, atomic::Ordering::Relaxed); + let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed); println!("Creating state for worker {worker_id}"); Ok(AppState { @@ -106,10 +101,15 @@ pub mod http { ) -> web::HttpResponse { println!("hit /playback, requesting {}", query.track_id); + let Ok(track_id) = i32::try_from(query.track_id) else { + return web::HttpResponse::BadRequest() + .body("track_id exceeds 32-bit unsigned integer limits"); + }; + let Ok(track) = state .library_handle .clone() - .with_library(|library| library.get_track_by_id(query.track_id)) + .with_library(|lib| models::Track::by_id(track_id).first(lib.conn())) else { return web::HttpResponse::NotFound().build(); }; @@ -161,27 +161,50 @@ pub mod http { } mod ws { + use std::collections::HashMap; + + use diesel::{RunQueryDsl, result::Error as DbError}; use ntex::{util::Bytes, web, web::ws::Message as WsMessage}; use serde::{Deserialize, Serialize}; - use crate::{library, models}; + use crate::{ + library, + models::{self, Artist, Collection, DetailedArtist, DetailedTrack, Track}, + }; // 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 { + #[derive(Deserialize)] + struct InMessage { pub id: i32, pub direction: MessageDirection, - pub params: MessageParams, + pub params: InMessageParams, } - #[derive(Serialize, Deserialize)] + #[derive(Serialize)] + struct OutMessage<'a> { + pub id: i32, + pub direction: MessageDirection, + pub params: OutMessageParams<'a>, + } + + #[derive(Deserialize)] #[serde(tag = "msg_type")] - enum MessageParams { + enum InMessageParams { Error(ErrorParams), GetTracks(GetTracks), - GetTracksResponse(GetTracksResponse), + + Ping(()), + Pong(()), + } + + #[derive(Serialize)] + #[serde(tag = "msg_type")] + enum OutMessageParams<'a> { + Error(ErrorParams), + + GetTracksResponse(GetTracksResponse<'a>), Ping(()), Pong(()), @@ -201,12 +224,12 @@ mod ws { // doesn't take any params for now and queries all tracks period #[derive(Serialize, Deserialize)] - struct GetTracks {} + struct GetTracks; // TODO: implement similar limit/offset/count behavior - #[derive(Serialize, Deserialize)] - struct GetTracksResponse { - items: Vec, + #[derive(Serialize)] + struct GetTracksResponse<'a> { + items: Vec>, } pub fn handle_message(library_handle: &library::Handle, msg_raw: Bytes) -> Option { @@ -224,25 +247,25 @@ mod ws { // 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), + InMessageParams::Ping(_) => generate_pong_response(&msg), + InMessageParams::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 { + fn decode_message(msg_raw: Bytes) -> Result { let msg_str = String::from_utf8_lossy(&msg_raw); - serde_json::from_str::(&msg_str) + serde_json::from_str::(&msg_str) } - fn generate_pong_response(msg: &Message) -> Option { - let msg = Message { + fn generate_pong_response(msg: &InMessage) -> Option { + let msg = OutMessage { id: msg.id, direction: MessageDirection::Response, - params: MessageParams::Pong(()), + params: OutMessageParams::Pong(()), }; let payload = serde_json::to_string(&msg) @@ -255,10 +278,10 @@ mod ws { fn generate_error_response(error_text: &str, id: Option) -> Option { let id = id.unwrap_or(-1); - let msg = Message { + let msg = OutMessage { id, direction: MessageDirection::Response, - params: MessageParams::Error(ErrorParams { + params: OutMessageParams::Error(ErrorParams { reason: error_text.into(), }), }; @@ -271,20 +294,82 @@ mod ws { fn get_tracks( library_handle: &library::Handle, - msg: &Message, + msg: &InMessage, params: &GetTracks, ) -> Option { // TODO: perhaps more descriptive errors - let tracks = library_handle.with_library(|lib| lib.get_tracks()); + // TODO: this is currently a stub that returns all tracks, artists, and collections, but + // eventually should only apply detail additions on top of the filters. it should query + // only the artists/collections that appear in the matched tracks/collections this could + // probably be separated into its own function as the end of a chain of a post search. + let query_results = library_handle.with_library( + |lib| -> Result<(Vec, Vec, Vec), DbError> { + Ok(( + Artist::all().load(lib.conn())?, + Collection::all().load(lib.conn())?, + Track::all().load(lib.conn())?, + )) + }, + ); - let Ok(tracks) = tracks else { + let Ok((artists, collections, tracks)) = query_results else { return generate_error_response("error getting tracks from database", Some(msg.id)); }; - let msg = Message { + let artists: HashMap = + HashMap::from_iter(artists.into_iter().map(|artist| { + ( + artist.id, + models::DetailedArtist { + id: artist.id, + original_name: artist.original_name, + display_name: artist.display_name, + }, + ) + })); + + let collections: HashMap = + HashMap::from_iter(collections.into_iter().map(|collection| { + let artist = collection.artist_id.map(|id| artists.get(&id)).flatten(); + + ( + collection.id, + models::DetailedCollection { + id: collection.id, + artist, + collection_type: collection.collection_type, + original_name: collection.original_name, + display_name: collection.display_name, + }, + ) + })); + + let tracks = tracks + .into_iter() + .map(|track| -> models::DetailedTrack { + let artist = track.artist_id.map(|id| artists.get(&id)).flatten(); + + let collection = track.collection_id.map(|id| collections.get(&id)).flatten(); + + models::DetailedTrack { + id: track.id, + file_path: track.file_path, + artist, + collection, + original_title: track.original_title, + display_title: track.display_title, + track_number: track.track_number, + genre: track.genre, + track_datetime: track.track_datetime, + length_sec: track.length_sec, + } + }) + .collect(); + + let msg = OutMessage { id: msg.id, direction: MessageDirection::Response, - params: MessageParams::GetTracksResponse(GetTracksResponse { items: tracks }), + params: OutMessageParams::GetTracksResponse(GetTracksResponse { items: tracks }), }; let payload = serde_json::to_string(&msg)