refactor: organize models and library, include detailed types in track JSON representation
- add basic searching query generators to `Artist`, `Collection`, `Track` - add detailed variants of models - split websocket message types to input/output-specific messages to constrain lifetimes - organize imports
This commit is contained in:
@@ -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<Vec<Track>> {
|
||||
schema::tracks::table
|
||||
.order(schema::tracks::id.asc())
|
||||
.load::<Track>(&mut self.conn)
|
||||
}
|
||||
|
||||
pub fn get_track_by_id(&mut self, id: u32) -> QueryResult<Track> {
|
||||
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::<Artist>(&mut self.conn)?;
|
||||
|
||||
let collections = schema::collections::table
|
||||
.order(schema::collections::id.asc())
|
||||
.load::<Collection>(&mut self.conn)?;
|
||||
|
||||
let tracks = schema::tracks::table
|
||||
.order(schema::tracks::id.asc())
|
||||
.load::<Track>(&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<ScannedTrackMetadata, TrackScanError> {
|
||||
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,
|
||||
})?;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use std::{
|
||||
fs::metadata,
|
||||
path::{Path, PathBuf},
|
||||
string,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use dotenvy::dotenv;
|
||||
|
||||
@@ -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<S, M> = Select<S, AsSelect<M, Sqlite>>;
|
||||
|
||||
#[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, Self> {
|
||||
artists::table.select(Self::as_select())
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn by_id(id: i32) -> _ {
|
||||
let all: All<artists::table, Self> = 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<artists::table, Self> = 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, Self> {
|
||||
collections::table.select(Self::as_select())
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn by_id(id: i32) -> _ {
|
||||
let all: All<collections::table, Self> = 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<collections::table, Self> = 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, Self> {
|
||||
tracks::table.select(Self::as_select())
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn by_id(id: i32) -> _ {
|
||||
let all: All<tracks::table, Self> = 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<tracks::table, Self> = 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<String>,
|
||||
pub display_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub genre: Option<String>,
|
||||
pub track_datetime: Option<String>,
|
||||
pub length_sec: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Insertable)]
|
||||
#[diesel(table_name = artists)]
|
||||
pub struct NewArtist<'a> {
|
||||
|
||||
163
src/net.rs
163
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<Self::State> {
|
||||
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<models::Track>,
|
||||
#[derive(Serialize)]
|
||||
struct GetTracksResponse<'a> {
|
||||
items: Vec<models::DetailedTrack<'a>>,
|
||||
}
|
||||
|
||||
pub fn handle_message(library_handle: &library::Handle, msg_raw: Bytes) -> Option<WsMessage> {
|
||||
@@ -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<Message, serde_json::Error> {
|
||||
fn decode_message(msg_raw: Bytes) -> Result<InMessage, serde_json::Error> {
|
||||
let msg_str = String::from_utf8_lossy(&msg_raw);
|
||||
|
||||
serde_json::from_str::<Message>(&msg_str)
|
||||
serde_json::from_str::<InMessage>(&msg_str)
|
||||
}
|
||||
|
||||
fn generate_pong_response(msg: &Message) -> Option<WsMessage> {
|
||||
let msg = Message {
|
||||
fn generate_pong_response(msg: &InMessage) -> Option<WsMessage> {
|
||||
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<i32>) -> Option<WsMessage> {
|
||||
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<WsMessage> {
|
||||
// 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<Artist>, Vec<Collection>, Vec<Track>), 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<i32, models::DetailedArtist> =
|
||||
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<i32, models::DetailedCollection> =
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user