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:
skybldev
2026-10-31 01:46:07 -04:00
parent 5836f671d6
commit 57d70e773f
4 changed files with 233 additions and 75 deletions

View File

@@ -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,
})?;

View File

@@ -1,8 +1,4 @@
use std::{
fs::metadata,
path::{Path, PathBuf},
string,
};
use std::path::Path;
use diesel::prelude::*;
use dotenvy::dotenv;

View File

@@ -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> {

View File

@@ -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)