From 0b73c055aa344d076937c6db8aef5ded84fe8ce1 Mon Sep 17 00:00:00 2001 From: KD Date: Fri, 23 May 2025 20:12:15 -0400 Subject: [PATCH] Create metadata (#133) * Added migration for metadata table used to store a song's metadata * Added comment for new table * Added endpoint to create metadata * Filling out code * Changed type of duration in request * Reorganizing code * Changing metadata table to song * Added TODO for data type discrepancy for the future * Fix migration syntax error * Added types to request * Added environment variable for root directory: * Moving keys to its own module * Moved environement related code to its own module * Added function to get root_directory * Added endpoint to create song * Minor fixes * Added test and refactored tests * Ironing out some kinks * Resolved test issue Turns out I was using the wrong id value * Added env variable in workflow * Added TODO * Updated env variable * clippy warning fixes * Making sure date_created is populated when returneD * Code cleanup * Added TODOs * Uncommenting tests * Code formatting --- .env.sample | 3 +- .github/workflows/workflow.yml | 1 + migrations/20250420185217_init_migration.sql | 23 ++ src/callers/mod.rs | 2 + src/callers/song.rs | 183 +++++++++++++++ src/environment.rs | 9 + src/keys.rs | 6 + src/main.rs | 229 ++++++++++++++----- 8 files changed, 396 insertions(+), 60 deletions(-) create mode 100644 src/environment.rs create mode 100644 src/keys.rs diff --git a/.env.sample b/.env.sample index 57c9f47..33ebf08 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,7 @@ SECRET_MAIN_KEY=refero34o8rfhfjn983thf39fhc943rf923n3h +ROOT_DIRECTORY=/home/icarus/mydata POSTGRES_MAIN_USER=icarus POSTGRES_MAIN_PASSWORD=password POSTGRES_MAIN_DB=icarus_db POSTGRES_MAIN_HOST=main_db -DATABASE_URL=postgres://${POSTGRES_MAIN_USER}:${POSTGRES_MAIN_PASSWORD}@${POSTGRES_MAIN_HOST}:5432/${POSTGRES_MAIN_DB} \ No newline at end of file +DATABASE_URL=postgres://${POSTGRES_MAIN_USER}:${POSTGRES_MAIN_PASSWORD}@${POSTGRES_MAIN_HOST}:5432/${POSTGRES_MAIN_DB} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index b61eba6..4bd1c03 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -66,6 +66,7 @@ jobs: - name: Run tests env: + ROOT_DIRECTORY: "/tmp" DATABASE_URL: "postgres://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}" run: cargo test --verbose diff --git a/migrations/20250420185217_init_migration.sql b/migrations/20250420185217_init_migration.sql index aca722f..22d78b0 100644 --- a/migrations/20250420185217_init_migration.sql +++ b/migrations/20250420185217_init_migration.sql @@ -26,3 +26,26 @@ CREATE TABLE IF NOT EXISTS "coverartQueue" ( -- Create an index for better query performance CREATE INDEX metadata_queue_data_metadata ON "metadataQueue" USING gin (metadata); + +-- Table to store a song's info +CREATE TABLE IF NOT EXISTS "song" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + artist TEXT NOT NULL, + album_artist TEXT NOT NULL, + album TEXT NOT NULL, + genre TEXT NOT NULL, + -- TODO: Address discrepancy of date and year at some point + -- date TEXT NOT NULL, + year INT NOT NULL, + track SMALLINT NOT NULL, + disc SMALLINT NOT NULL, + track_count SMALLINT NOT NULL, + disc_count SMALLINT NOT NULL, + duration INT NOT NULL, + audio_type TEXT NOT NULL, + date_created timestamptz DEFAULT now(), + filename TEXT NOT NULL, + directory TEXT NOT NULL, + user_id UUID NULL +); diff --git a/src/callers/mod.rs b/src/callers/mod.rs index 1dbc6c4..99e918a 100644 --- a/src/callers/mod.rs +++ b/src/callers/mod.rs @@ -11,4 +11,6 @@ pub mod endpoints { pub const QUEUECOVERART: &str = "/api/v2/coverart/queue"; pub const QUEUECOVERARTDATA: &str = "/api/v2/coverart/queue/data"; pub const QUEUECOVERARTLINK: &str = "/api/v2/coverart/queue/link"; + + pub const CREATESONG: &str = "/api/v2/song"; } diff --git a/src/callers/song.rs b/src/callers/song.rs index b4ddeef..66731c2 100644 --- a/src/callers/song.rs +++ b/src/callers/song.rs @@ -13,6 +13,69 @@ pub mod request { pub status: String, } } + + pub mod create_metadata { + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct Request { + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + pub genre: String, + pub date: String, + pub track: i32, + pub disc: i32, + pub track_count: i32, + pub disc_count: i32, + pub duration: i32, + pub audio_type: String, + pub user_id: uuid::Uuid, + pub song_queue_id: uuid::Uuid, + } + + impl Request { + pub fn is_valid(&self) -> bool { + !self.title.is_empty() + || !self.artist.is_empty() + || !self.album_artist.is_empty() + || !self.album.is_empty() + || !self.genre.is_empty() + || !self.date.is_empty() + || self.track > 0 + || self.disc > 0 + || self.track_count > 0 + || self.disc_count > 0 + || self.duration > 0 + || !self.audio_type.is_empty() + || !self.user_id.is_nil() + || !self.song_queue_id.is_nil() + } + + pub fn to_song(&self) -> icarus_models::song::Song { + icarus_models::song::Song { + id: uuid::Uuid::nil(), + title: self.title.clone(), + artist: self.artist.clone(), + album_artist: self.album_artist.clone(), + album: self.album.clone(), + genre: self.genre.clone(), + year: self.date[..3].parse().unwrap(), + track: self.track, + disc: self.disc, + track_count: self.track_count, + disc_count: self.disc_count, + duration: self.duration, + audio_type: self.audio_type.clone(), + user_id: self.user_id, + // TODO: Change the type of this in icarus_models lib + date_created: String::new(), + filename: String::new(), + data: Vec::new(), + directory: String::new(), + } + } + } + } } pub mod response { @@ -55,8 +118,18 @@ pub mod response { pub data: Vec>, } } + + pub mod create_metadata { + #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + pub struct Response { + pub message: String, + pub data: Vec, + } + } } +// TODO: Might make a distinction between year and date in a song's tag at some point + pub mod status { pub const PENDING: &str = "pending"; pub const PROCESSING: &str = "processing"; @@ -67,6 +140,60 @@ pub mod status { } } +mod song_db { + use sqlx::Row; + + // TODO: Change first parameter of return value from string to a time type + pub async fn insert( + pool: &sqlx::PgPool, + song: &icarus_models::song::Song, + ) -> Result<(String, uuid::Uuid), sqlx::Error> { + let result = sqlx::query( + r#" + INSERT INTO "song" (title, artist, album_artist, album, genre, year, track, disc, track_count, disc_count, duration, audio_type, filename, directory, user_id) + VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING date_created, id; + "# + ) + .bind(&song.title) + .bind(&song.artist) + .bind(&song.album_artist) + .bind(&song.album) + .bind(&song.genre) + .bind(song.year) + .bind(song.track) + .bind(song.disc) + .bind(song.track_count) + .bind(song.disc_count) + .bind(song.duration) + .bind(&song.audio_type) + .bind(&song.filename) + .bind(&song.directory) + .bind(song.user_id) + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Error inserting query: {:?}", e); + }); + + match result { + Ok(row) => { + let id: uuid::Uuid = row + .try_get("id") + .map_err(|_e| sqlx::Error::RowNotFound) + .unwrap(); + let date_created_time: time::OffsetDateTime = row + .try_get("date_created") + .map_err(|_e| sqlx::Error::RowNotFound) + .unwrap(); + let date_created = date_created_time.to_string(); + + Ok((date_created, id)) + } + Err(_) => Err(sqlx::Error::RowNotFound), + } + } +} + mod song_queue { use sqlx::Row; @@ -289,6 +416,7 @@ pub mod endpoint { data.len() ); + // TODO: Remove this // Save the file to disk let mut file = std::fs::File::create(&file_name).unwrap(); file.write_all(&data).unwrap(); @@ -456,4 +584,59 @@ pub mod endpoint { (axum::http::StatusCode::NOT_FOUND, axum::Json(response)) } } + + pub async fn create_metadata( + axum::Extension(pool): axum::Extension, + axum::Json(payload): axum::Json, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + let mut response = super::response::create_metadata::Response::default(); + + if payload.is_valid() { + let mut song = payload.to_song(); + song.filename = + song.generate_filename(icarus_models::types::MusicTypes::FlacExtension, true); + song.directory = crate::environment::get_root_directory().await.unwrap(); + + match song_queue::get_data(&pool, &payload.song_queue_id).await { + Ok(data) => { + song.data = data; + let dir = std::path::Path::new(&song.directory); + let save_path = dir.join(&song.filename); + + let mut file = std::fs::File::create(&save_path).unwrap(); + file.write_all(&song.data).unwrap(); + + match song.song_path() { + Ok(_) => match super::song_db::insert(&pool, &song).await { + Ok((date_created, id)) => { + song.id = id; + song.date_created = date_created; + response.data.push(song); + + (axum::http::StatusCode::OK, axum::Json(response)) + } + Err(err) => { + response.message = format!("{:?} song {:?}", err.to_string(), song); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + }, + Err(err) => { + response.message = err.to_string(); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + } + } + Err(err) => { + response.message = err.to_string(); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + } + } else { + response.message = String::from("Request body is not valid"); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + } } diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..27446ff --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,9 @@ +pub async fn get_db_url() -> String { + dotenvy::dotenv().ok(); + std::env::var(crate::keys::DBURL).expect(crate::keys::error::ERROR) +} + +pub async fn get_root_directory() -> Result { + dotenvy::dotenv().ok(); + std::env::var(crate::keys::ROOT_DIRECTORY) +} diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..3408552 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,6 @@ +pub const DBURL: &str = "DATABASE_URL"; +pub const ROOT_DIRECTORY: &str = "ROOT_DIRECTORY"; + +pub mod error { + pub const ERROR: &str = "DATABASE_URL must be set in .env"; +} diff --git a/src/main.rs b/src/main.rs index 038c9fc..d9d4892 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,17 @@ pub mod callers; +pub mod environment; +pub mod keys; pub mod db { use sqlx::postgres::PgPoolOptions; - use std::env; pub mod connection_settings { pub const MAXCONN: u32 = 10; } - pub mod keys { - pub const DBURL: &str = "DATABASE_URL"; - - pub mod error { - pub const ERROR: &str = "DATABASE_URL must be set in .env"; - } - } - pub async fn create_pool() -> Result { - let database_url = get_db_url().await; + let database_url = crate::environment::get_db_url().await; println!("Database url: {:?}", database_url); PgPoolOptions::new() @@ -27,11 +20,6 @@ pub mod db { .await } - async fn get_db_url() -> String { - dotenvy::dotenv().ok(); - env::var(keys::DBURL).expect(keys::error::ERROR) - } - pub async fn migrations(pool: &sqlx::PgPool) { // Run migrations using the sqlx::migrate! macro // Assumes your migrations are in a ./migrations folder relative to Cargo.toml @@ -109,6 +97,10 @@ pub mod init { crate::callers::endpoints::QUEUECOVERARTLINK, patch(crate::callers::coverart::endpoint::link), ) + .route( + crate::callers::endpoints::CREATESONG, + post(crate::callers::song::endpoint::create_metadata), + ) } pub async fn app() -> axum::Router { @@ -125,18 +117,23 @@ pub mod init { } } +// TODO: Move elsewhere fn get_full() -> String { get_address() + ":" + &get_port() } +// TODO: Move elsewhere fn get_address() -> String { String::from("0.0.0.0") } +// TODO: Move elsewhere fn get_port() -> String { String::from("3000") } +// TODO: Move elsewhere pub const ROOT: &str = "/"; +// TODO: Move elsewhere // basic handler that responds with a static string pub async fn root() -> &'static str { "Hello, World!" @@ -157,13 +154,13 @@ mod tests { mod db_mgr { use std::str::FromStr; - use crate::db; + use crate::keys; pub const LIMIT: usize = 6; pub async fn get_pool() -> Result { dotenvy::dotenv().ok(); - let tm_db_url = std::env::var(db::keys::DBURL).expect("DATABASE_URL must be present"); + let tm_db_url = crate::environment::get_db_url().await; let tm_options = sqlx::postgres::PgConnectOptions::from_str(&tm_db_url).unwrap(); sqlx::PgPool::connect_with(tm_options).await } @@ -176,8 +173,7 @@ mod tests { pub async fn connect_to_db(db_name: &str) -> Result { dotenvy::dotenv().ok(); - let db_url = - std::env::var(db::keys::DBURL).expect("DATABASE_URL must be set for tests"); + let db_url = crate::environment::get_db_url().await; let options = sqlx::postgres::PgConnectOptions::from_str(&db_url)?.database(db_name); sqlx::PgPool::connect_with(options).await } @@ -206,7 +202,7 @@ mod tests { pub fn get_database_name() -> Result> { dotenvy::dotenv().ok(); // Load .env file if it exists - match std::env::var(db::keys::DBURL) { + match std::env::var(keys::DBURL) { Ok(database_url) => { let parsed_url = url::Url::parse(&database_url)?; if parsed_url.scheme() == "postgres" || parsed_url.scheme() == "postgresql" { @@ -273,6 +269,22 @@ mod tests { app.clone().oneshot(fetch_req).await } + async fn fetch_metadata_queue_req( + app: &axum::Router, + id: &uuid::Uuid, + ) -> Result { + let uri = format!("{}?id={}", crate::callers::endpoints::QUEUEMETADATA, id); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri(uri) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::empty()) + .unwrap(); + + app.clone().oneshot(req).await + } + async fn fetch_queue_data_req( app: &axum::Router, id: &uuid::Uuid, @@ -312,6 +324,22 @@ mod tests { app.clone().oneshot(req).await } + async fn queue_metadata_req( + app: &axum::Router, + id: &uuid::Uuid, + ) -> Result { + let payload = payload_data(&id).await; + + let req = axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(crate::callers::endpoints::QUEUEMETADATA) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(payload.to_string())) + .unwrap(); + + app.clone().oneshot(req).await + } + async fn coverart_queue_song_queue_link_req( app: &axum::Router, coverart_id: &uuid::Uuid, @@ -540,20 +568,8 @@ mod tests { get_resp_data::(response).await; assert_eq!(false, resp.data.is_empty(), "Should not be empty"); assert_eq!(false, resp.data[0].is_nil(), "Should not be empty"); - let new_payload = payload_data(&resp.data[0]).await; - match app - .clone() - .oneshot( - axum::http::Request::builder() - .method(axum::http::Method::POST) - .uri(crate::callers::endpoints::QUEUEMETADATA) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::from(new_payload.to_string())) - .unwrap(), - ) - .await - { + match queue_metadata_req(&app, &resp.data[0]).await { Ok(response) => { let resp = get_resp_data::(response) @@ -599,20 +615,8 @@ mod tests { get_resp_data::(response).await; assert_eq!(false, resp.data.is_empty(), "Should not be empty"); assert_eq!(false, resp.data[0].is_nil(), "Should not be empty"); - let new_payload = payload_data(&resp.data[0]).await; - match app - .clone() - .oneshot( - axum::http::Request::builder() - .method(axum::http::Method::POST) - .uri(crate::callers::endpoints::QUEUEMETADATA) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::from(new_payload.to_string())) - .unwrap(), - ) - .await - { + match queue_metadata_req(&app, &resp.data[0]).await { Ok(response) => { let resp = get_resp_data::(response) @@ -620,20 +624,8 @@ mod tests { assert_eq!(false, resp.data.is_empty(), "Should not be empty"); let id = resp.data[0]; - let uri = format!("{}?id={}", crate::callers::endpoints::QUEUEMETADATA, id); - match app - .clone() - .oneshot( - axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri(uri) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - { + match fetch_metadata_queue_req(&app, &id).await { Ok(response) => { let resp = get_resp_data::< crate::callers::metadata::response::fetch_metadata::Response, @@ -1199,4 +1191,123 @@ mod tests { let _ = db_mgr::drop_database(&tm_pool, &db_name).await; } + + #[tokio::test] + async fn test_create_song() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + db::migrations(&pool).await; + + let app = init::app(pool).await; + + // Send request + match song_queue_req(&app).await { + Ok(response) => { + let resp = + get_resp_data::(response).await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + assert_eq!(false, resp.data[0].is_nil(), "Should not be empty"); + + match queue_metadata_req(&app, &resp.data[0]).await { + Ok(response) => { + let resp = + get_resp_data::(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + + let id = resp.data[0]; + + match fetch_metadata_queue_req(&app, &id).await { + Ok(response) => { + let resp = get_resp_data::< + crate::callers::metadata::response::fetch_metadata::Response, + >(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Data should not be empty"); + let song_q_id = resp.data[0].song_queue_id; + + let payload = serde_json::json!({ + "title": "Power of Soul", + "artist": "Jimmi Hendrix", + "album_artist": "Jimmi Hendrix", + "album": "Machine Gun", + "genre": "Psychadelic Rock", + "date": "2016-01-01", + "track": 1, + "disc": 1, + "track_count": 11, + "disc_count": 1, + "duration": 330, + "audio_type": "flac", + "user_id": "d6e159c1-9648-4c85-81e5-52f502ff53e4", + "song_queue_id": song_q_id + }); + + eprintln!("Payload: {:?}", payload); + + match app + .clone() + .oneshot( + axum::http::Request::builder() + .method(axum::http::Method::POST) + .uri(crate::callers::endpoints::CREATESONG) + .header( + axum::http::header::CONTENT_TYPE, + "application/json", + ) + .body(axum::body::Body::from(payload.to_string())) + .unwrap(), + ) + .await + { + Ok(response) => { + let resp = get_resp_data::(response).await; + assert_eq!( + false, + resp.data.is_empty(), + "No songs found, Response {:?}", + resp + ); + let song = &resp.data[0]; + let song_id = song.id; + assert_eq!( + false, + song_id.is_nil(), + "Song id should not be nil {:?}", + song + ); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + }; + + let _ = db_mgr::drop_database(&tm_pool, &db_name).await; + } }