Compare commits

..

10 Commits

Author SHA1 Message Date
KD 679a4ad322 Auth (#169)
* Added jwt crate

* Saving changes

Might go a different route later

* Crate changes

* Adding auth module

* Saving changes

* Added jsonwebtoken crate

* Saving changes

* Added feature to tower-http crate

* Using secret key from env file

* Locking down one endpoint to start

* Warning fix

* Got auth functioning

* Updated icarus_models

* Fixed auth in tests

* Warning fixes

* Code formatting

* Added auth to the other endpoints

* Version bump

* This should fix the pipeline test failure
2025-08-01 15:16:00 -04:00
KD 9aefeace23 Use multiple files in tests (#168)
* File renamed

* Filename change in the code as well

* Added more migration sql for tests

* Using different song for a test

* Changes to test and helper functions

* Workflow change

* Code formatting

* Version bump
2025-07-30 15:28:47 -04:00
KD a0c6ae65a3 Delete song endpoint (#167)
* Added initial code for delete song endpoint

* Saving changes

* More changes

* Made delete song endpoint available

* Response change

* Added test function

* Changes to code

Fixing errors

* How did I miss this?

* Got the test working

* Code cleanup and formatting

* Version bump

* Code changes

* Workflow change
2025-07-29 21:34:16 -04:00
KD c7230d3b32 Download coverart endpoint (#166)
* Added download coverart endpoint

* Endpoint is now available

* Added test

* Code formatting

* Version bump
2025-07-28 17:05:42 -04:00
KD 703fc49f32 Downloading song endpoint (#165)
* Added initial code for downloading song endpoint

* Finished download song endpoint

* Making download endpoint available

* Added test

* Code formatting

* Version bump
2025-07-28 16:40:14 -04:00
KD e98e055de0 Stream song (#164)
* Added crates and dependency features

* Added endpoint to stream songs

* Changed name of function

* Song stream endpoint is now available

* Endpoint is now working

* Added test

* Warning fixes

* Code formatting

* Added TODO for later

* Test fix

* Version bump
2025-07-27 14:20:42 -04:00
KD a8603361c0 Get coverart (#163)
* Added initial code to get coverart endpoint

* Test migration cleanup

* Updated icarus_models

* Made endpoint evailable

* Added test

* Version bump

* Code formatting
2025-07-26 14:12:03 -04:00
KD dbda9a3897 Get songs endpoint (#162)
* Added test_migrations directory

* Added skeleton endpoint to get songs

* Code formatting

* Added more code

* Added TODO

* Code formatting

* Updated album in album json file

* Changing directory name

* Test refactoring

* Forgot to include this

* Added test for getting songs and added function for test_migrations

* Adding test migrations

* Removing placeholder

* Renamed

* Renamed

* Fixed what caused test failure

* Created migration with cli command

Creating test_migrations/20250725213448_migration_name.sql

* Migration changes

* Removing migration

* More migration changes

* Made endpoint available

* Migration changes

* Got the test to work

* Cleaned up test

* Code formatting

* More cleanup

* Version bump
2025-07-25 18:20:28 -04:00
KD a987e438c4 Added flac files used for testing (#161)
* Added flac files used for testing

* Refactor test

* Added CoverArt file for test files

* Added album json file for testing

* Modifying test json file

* Modifying test file for consistency

* Test refactoring

* Test file changes

* Removing commented code

* Removing files used for testing

* Removing placeholder

* Code formatting
2025-07-25 16:09:01 -04:00
KD a150d34c6c Added release workflow (#160)
* Added release workflow

* Temp change

* File extension change

* Syntax change

* Removing that temp change
2025-07-22 21:59:20 -04:00
26 changed files with 1711 additions and 57 deletions
-2
View File
@@ -3,8 +3,6 @@ name: Release Tagging
on:
push:
branches: [ "v0.2" ]
pull_request:
branches: [ "v0.2" ]
jobs:
release:
+3 -1
View File
@@ -68,7 +68,9 @@ jobs:
env:
ROOT_DIRECTORY: "/tmp"
DATABASE_URL: "postgres://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}"
run: cargo test --verbose
run: |
cat .env.sample | head -1 > .env
cargo test --verbose --
- name: Run clippy
run: cargo clippy -- -D warnings
+8
View File
@@ -0,0 +1,8 @@
{
"dev": {
"migrations": "./migrations"
},
"test": {
"migrations": "./test_migrations"
}
}
Generated
+198 -6
View File
@@ -17,12 +17,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "atoi"
version = "2.0.0"
@@ -93,6 +108,29 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.75"
@@ -108,6 +146,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -222,6 +266,17 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -588,8 +643,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -630,6 +687,16 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "hdrhistogram"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
dependencies = [
"byteorder",
"num-traits",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -752,18 +819,24 @@ dependencies = [
[[package]]
name = "icarus"
version = "0.1.93"
version = "0.1.101"
dependencies = [
"axum",
"axum-extra",
"base64 0.21.7",
"common-multipart-rfc7578",
"futures",
"icarus_envy",
"icarus_meta",
"icarus_models",
"josekit",
"jsonwebtoken",
"mime_guess",
"serde",
"serde_json",
"sqlx",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-util",
@@ -793,9 +866,10 @@ dependencies = [
[[package]]
name = "icarus_models"
version = "0.4.5"
source = "git+ssh://git@git.kundeng.us/phoenix/icarus_models.git?tag=v0.4.5-devel-655d05dabb-111#655d05dabbdadb9b28940564a1eb82470aa4f166"
version = "0.5.2"
source = "git+ssh://git@git.kundeng.us/phoenix/icarus_models.git?tag=v0.5.2-devel-d3251f935e-111#d3251f935e76caa110727203d910ee0683a668a0"
dependencies = [
"josekit",
"rand 0.9.1",
"serde",
"serde_json",
@@ -926,6 +1000,23 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "josekit"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a808e078330e6af222eb0044b71d4b1ff981bfef43e7bc8133a88234e0c86a0c"
dependencies = [
"anyhow",
"base64 0.22.1",
"flate2",
"openssl",
"regex",
"serde",
"serde_json",
"thiserror 2.0.12",
"time",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -936,6 +1027,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1123,6 +1229,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
@@ -1285,6 +1401,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -1455,6 +1581,49 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.8"
@@ -1570,6 +1739,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"indexmap",
"itoa",
"memchr",
"ryu",
@@ -1654,6 +1824,18 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.12",
"time",
]
[[package]]
name = "slab"
version = "0.4.10"
@@ -1717,7 +1899,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"crc",
"crossbeam-queue",
@@ -1793,7 +1975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags",
"byteorder",
"bytes",
@@ -1837,7 +2019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags",
"byteorder",
"crc",
@@ -2125,9 +2307,13 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"hdrhistogram",
"indexmap",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -2264,6 +2450,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.4"
+11 -5
View File
@@ -1,24 +1,30 @@
[package]
name = "icarus"
version = "0.1.93"
version = "0.1.101"
edition = "2024"
rust-version = "1.88"
[dependencies]
axum = { version = "0.8.4", features = ["multipart"] }
axum-extra = { version = "0.10.1", features = ["cookie"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.140" }
tower = { version = "0.5.2" }
tower = { version = "0.5.2", features = ["full"] }
tokio = { version = "1.45.1", features = ["full"] }
tokio-util = { version = "0.7.15" }
tower-http = { version = "0.6.6", features = ["timeout"] }
tokio-util = { version = "0.7.15", features = ["io"] }
tower-http = { version = "0.6.6", features = ["cors", "timeout"] }
tracing-subscriber = "0.3.19"
futures = { version = "0.3.31" }
mime_guess = { version = "2.0.5" }
uuid = { version = "1.17.0", features = ["v4", "serde"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "time", "uuid"] }
time = { version = "0.3.41", features = ["formatting", "macros", "parsing", "serde"] }
thiserror = "1.0"
base64 = "0.21"
jsonwebtoken = { version = "9.3.1" }
josekit = { version = "0.10.1" }
icarus_meta = { git = "ssh://git@git.kundeng.us/phoenix/icarus_meta.git", tag = "v0.3.0-devel-f4b71de969-680" }
icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.4.5-devel-655d05dabb-111" }
icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.5.2-devel-d3251f935e-111" }
icarus_envy = { git = "ssh://git@git.kundeng.us/phoenix/icarus_envy.git", tag = "v0.3.0-devel-d73fba9899-006" }
[dev-dependencies]
+121
View File
@@ -0,0 +1,121 @@
// use std::collections::BTreeMap;
// use std::sync::Arc;
use axum::{
Json,
http::{Request, StatusCode},
middleware::Next,
response::IntoResponse,
};
use axum_extra::extract::cookie::CookieJar;
use jsonwebtoken::{DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
// use serde_json::{json, Value};
use thiserror::Error;
// use time::OffsetDateTime;
fn deserialize_i64_from_f64<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: serde::Deserializer<'de>,
{
let val = f64::deserialize(deserializer)?;
// Handle NaN and infinity cases
if val.is_nan() || val.is_infinite() {
return Err(serde::de::Error::custom("invalid float value"));
}
// Round to nearest integer and convert
let rounded = val.round();
// Check if the rounded value can fit in i64
if rounded < (i64::MIN as f64) || rounded > (i64::MAX as f64) {
return Err(serde::de::Error::custom("float out of i64 range"));
}
Ok(rounded as i64)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserClaims {
pub iss: String,
pub aud: String, // Audience
pub sub: String, // Subject (user ID)
#[serde(deserialize_with = "deserialize_i64_from_f64")]
pub exp: i64, // Expiration time (UTC timestamp)
#[serde(deserialize_with = "deserialize_i64_from_f64")]
pub iat: i64, // Issued at (UTC timestamp)
// pub azp: String,
// pub gty: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<String>>, // Optional roles
}
#[derive(Error, Debug)]
pub enum JwtError {
#[error("Token creation failed")]
TokenCreation,
#[error("Token verification failed")]
TokenVerification,
#[error("Invalid token")]
InvalidToken,
#[error("Token expired")]
ExpiredToken,
#[error("Invalid key")]
InvalidKey,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub status: &'static str,
pub message: String,
}
pub async fn auth<B>(
cookie_jar: CookieJar,
req: Request<axum::body::Body>,
next: Next,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
println!("Cookie: {cookie_jar:?}");
let token = cookie_jar
.get("token")
.map(|cookie| cookie.value().to_string())
.or_else(|| {
req.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|auth_header| auth_header.to_str().ok())
.and_then(|auth_value| {
println!("Auth value: {auth_value:?}");
auth_value.strip_prefix("Bearer ").map(String::from)
})
});
let token = token.ok_or_else(|| {
let json_error = ErrorResponse {
status: "fail",
message: "You are not logged in, please provide token".to_string(),
};
(StatusCode::UNAUTHORIZED, Json(json_error))
})?;
let secret_key = icarus_envy::environment::get_secret_main_key().await;
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
validation.set_audience(&["icarus"]); // Must match exactly what's in the token
let claims = decode::<UserClaims>(
&token,
// TODO: Replace with code to get secret from env
&DecodingKey::from_secret(secret_key.as_ref()),
&validation,
)
.map_err(|err| {
eprintln!("Error: {err:?}");
let json_error = ErrorResponse {
status: "fail",
message: "Invalid token - Error decoding claims".to_string(),
};
(StatusCode::UNAUTHORIZED, Json(json_error))
})?
.claims;
println!("Claims: {claims:?}");
Ok(next.run(req).await)
}
+204
View File
@@ -45,6 +45,13 @@ pub mod request {
pub coverart_queue_id: uuid::Uuid,
}
}
pub mod get_coverart {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Params {
pub id: Option<uuid::Uuid>,
}
}
}
pub mod response {
@@ -99,6 +106,14 @@ pub mod response {
pub data: Vec<uuid::Uuid>,
}
}
pub mod get_coverart {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<icarus_models::coverart::CoverArt>,
}
}
}
pub mod db {
@@ -321,6 +336,128 @@ pub mod cov_db {
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<icarus_models::coverart::CoverArt, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, title, path, song_id FROM "coverart" WHERE id = $1;
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(icarus_models::coverart::CoverArt {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
title: row
.try_get("title")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
path: row
.try_get("path")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
data: Vec::new(),
song_id: row
.try_get("song_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart_with_song_id(
pool: &sqlx::PgPool,
song_id: &uuid::Uuid,
) -> Result<icarus_models::coverart::CoverArt, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, title, path, song_id FROM "coverart" WHERE song_id = $1;
"#,
)
.bind(song_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(icarus_models::coverart::CoverArt {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
title: row
.try_get("title")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
path: row
.try_get("path")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
data: Vec::new(),
song_id: row
.try_get("song_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn delete_coverart(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<icarus_models::coverart::CoverArt, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM "coverart"
WHERE id = $1
RETURNING id, title, path, song_id
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error deleting data: {e:?}");
});
match result {
Ok(row) => Ok(icarus_models::coverart::CoverArt {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
title: row
.try_get("title")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
path: row
.try_get("path")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
song_id: row
.try_get("song_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
data: Vec::new(),
}),
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
}
pub mod endpoint {
@@ -507,7 +644,10 @@ pub mod endpoint {
match super::cov_db::create(&pool, &coverart, &song.id).await {
Ok(id) => {
// TODO: Populate song_id
coverart.id = id;
println!("Cover Art created");
response.message = String::from("Successful");
response.data.push(coverart);
@@ -560,4 +700,68 @@ pub mod endpoint {
}
}
}
pub async fn get_coverart(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Query(params): axum::extract::Query<super::request::get_coverart::Params>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::get_coverart::Response>,
) {
let mut response = super::response::get_coverart::Response::default();
match params.id {
Some(id) => match super::cov_db::get_coverart(&pool, &id).await {
Ok(coverart) => {
response.data.push(coverart);
response.message = String::from(super::super::response::SUCCESSFUL);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
None => {
response.message = String::from("Invalid parameters");
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
pub async fn download_coverart(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> (axum::http::StatusCode, axum::response::Response) {
match super::cov_db::get_coverart(&pool, &id).await {
Ok(coverart) => match coverart.to_data() {
Ok(data) => {
let bytes = axum::body::Bytes::from(data);
let mut response = bytes.into_response();
let headers = response.headers_mut();
// TODO: Address hard coding
headers.insert(
axum::http::header::CONTENT_TYPE,
"audio/jpg".parse().unwrap(),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.jpg\"")
.parse()
.unwrap(),
);
(axum::http::StatusCode::OK, response)
}
Err(_err) => (
axum::http::StatusCode::NOT_FOUND,
axum::response::Response::default(),
),
},
Err(_err) => (
axum::http::StatusCode::NOT_FOUND,
axum::response::Response::default(),
),
}
}
}
+6
View File
@@ -16,7 +16,13 @@ pub mod endpoints {
pub const QUEUECOVERARTDATAWIPE: &str = "/api/v2/coverart/queue/data/wipe";
pub const CREATESONG: &str = "/api/v2/song";
pub const GETSONGS: &str = "/api/v2/song";
pub const STREAMSONG: &str = "/api/v2/song/stream/{id}";
pub const DOWNLOADSONG: &str = "/api/v2/song/download/{id}";
pub const DELETESONG: &str = "/api/v2/song/{id}";
pub const CREATECOVERART: &str = "/api/v2/coverart";
pub const GETCOVERART: &str = "/api/v2/coverart";
pub const DOWNLOADCOVERART: &str = "/api/v2/coverart/download/{id}";
}
pub mod response {
+336
View File
@@ -92,6 +92,13 @@ pub mod request {
pub user_id: uuid::Uuid,
}
}
pub mod get_songs {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Params {
pub id: Option<uuid::Uuid>,
}
}
}
pub mod response {
@@ -158,6 +165,28 @@ pub mod response {
pub data: Vec<uuid::Uuid>,
}
}
pub mod get_songs {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<icarus_models::song::Song>,
}
}
pub mod delete_song {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct SongAndCoverArt {
pub song: icarus_models::song::Song,
pub coverart: icarus_models::coverart::CoverArt,
}
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<SongAndCoverArt>,
}
}
}
// TODO: Might make a distinction between year and date in a song's tag at some point
@@ -321,6 +350,105 @@ pub mod song_db {
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn delete_song(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<icarus_models::song::Song, sqlx::Error> {
let result = sqlx::query(
// icarus_models::song::Song,
r#"
DELETE FROM "song"
WHERE id = $1
RETURNING id, title, artist, album, album_artist, genre, year, disc, track, track_count, disc_count, duration, audio_type, date_created, filename, directory, user_id
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error deleting data: {e:?}")
});
match result {
Ok(row) => {
let date_created_time: time::OffsetDateTime = row
.try_get("date_created")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap();
Ok(icarus_models::song::Song {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
title: row
.try_get("title")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
artist: row
.try_get("artist")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
album_artist: row
.try_get("album_artist")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
album: row
.try_get("album")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
genre: row
.try_get("genre")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
year: row
.try_get("year")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
track: row
.try_get("track")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
disc: row
.try_get("disc")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
track_count: row
.try_get("track_count")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
disc_count: row
.try_get("disc_count")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
duration: row
.try_get("duration")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
audio_type: row
.try_get("audio_type")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
filename: row
.try_get("filename")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
directory: row
.try_get("directory")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
date_created: date_created_time.to_string(),
user_id: row
.try_get("user_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
data: Vec::new(),
})
}
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
}
mod song_queue {
@@ -730,6 +858,7 @@ pub mod endpoint {
}
}
// TODO: Rename
pub async fn download_flac(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
@@ -968,4 +1097,211 @@ pub mod endpoint {
}
}
}
pub async fn get_songs(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Query(params): axum::extract::Query<super::request::get_songs::Params>,
) -> (
axum::http::StatusCode,
Json<super::response::get_songs::Response>,
) {
let mut response = super::response::get_songs::Response::default();
match params.id {
Some(id) => match super::song_db::get_song(&pool, &id).await {
Ok(song) => {
response.message = String::from(super::super::response::SUCCESSFUL);
response.data.push(song);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
None => {
response.message = String::from("Invalid parameters");
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
pub async fn stream_song(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> impl IntoResponse {
match super::song_db::get_song(&pool, &id).await {
Ok(song) => {
let song_path = song.song_path().unwrap();
let path = std::path::Path::new(&song_path);
if !path.starts_with(&song.directory) || !path.exists() {
return Err((axum::http::StatusCode::NOT_FOUND, "File not found"));
}
let file = match tokio::fs::File::open(&path).await {
Ok(file) => file,
Err(_) => return Err((axum::http::StatusCode::NOT_FOUND, "File not found")),
};
let file_size = match file.metadata().await {
Ok(meta) => meta.len(),
Err(_) => {
return Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Could not read file",
));
}
};
let mime = mime_guess::from_path(path).first_or_octet_stream();
let stream = tokio_util::io::ReaderStream::new(file);
let rep = axum::response::Response::builder()
.header("content-type", mime.to_string())
.header("accept-ranges", "bytes")
.header("content-length", file_size.to_string())
.body(axum::body::Body::from_stream(stream))
.unwrap();
Ok(rep)
}
Err(_err) => Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Could not find file",
)),
}
}
pub async fn download_song(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> (axum::http::StatusCode, axum::response::Response) {
match super::song_db::get_song(&pool, &id).await {
Ok(song) => match song.to_data() {
Ok(data) => {
let bytes = axum::body::Bytes::from(data);
let mut response = bytes.into_response();
let headers = response.headers_mut();
headers.insert(
axum::http::header::CONTENT_TYPE,
"audio/flac".parse().unwrap(),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.flac\"")
.parse()
.unwrap(),
);
(axum::http::StatusCode::OK, response)
}
Err(_err) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::response::Response::default(),
),
},
Err(_err) => (
axum::http::StatusCode::NOT_FOUND,
axum::response::Response::default(),
),
}
}
pub async fn delete_song(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::delete_song::Response>,
) {
let mut response = super::response::delete_song::Response::default();
match super::song_db::get_song(&pool, &id).await {
Ok(song) => {
match super::super::coverart::cov_db::get_coverart_with_song_id(&pool, &song.id)
.await
{
Ok(coverart) => {
let coverart_path = std::path::Path::new(&coverart.path);
if coverart_path.exists() {
match song.song_path() {
Ok(song_path) => {
match super::song_db::delete_song(&pool, &song.id).await {
Ok(deleted_song) => {
match super::super::coverart::cov_db::delete_coverart(
&pool,
&coverart.id,
)
.await
{
Ok(deleted_coverart) => {
match std::fs::remove_file(song_path) {
Ok(_) => match std::fs::remove_file(
&coverart.path,
) {
Ok(_) => {
response.message = String::from(super::super::response::SUCCESSFUL);
response.data.push(super::response::delete_song::SongAndCoverArt{ song: deleted_song, coverart: deleted_coverart });
(
axum::http::StatusCode::OK,
axum::Json(response),
)
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response))
}
},
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response))
}
}
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response))
}
}
}
Err(err) => {
response.message = err.to_string();
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(response),
)
}
}
}
Err(err) => {
response.message = err.to_string();
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(response),
)
}
}
} else {
response.message =
String::from("Could not locate coverart on the filesystem");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(response),
)
}
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::NOT_FOUND, axum::Json(response))
}
}
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::NOT_FOUND, axum::Json(response))
}
}
}
}
+687 -43
View File
@@ -1,3 +1,4 @@
pub mod auth;
pub mod callers;
pub mod db {
@@ -44,76 +45,150 @@ async fn main() {
}
pub mod init {
use axum::routing::{get, patch, post};
use axum::routing::{delete, get, patch, post};
use std::time::Duration;
use tower_http::timeout::TimeoutLayer;
use axum::http::{
HeaderValue, Method,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
};
pub async fn routes() -> axum::Router {
axum::Router::new()
.route(crate::ROOT, get(crate::root))
.route(
crate::callers::endpoints::QUEUESONG,
post(crate::callers::song::endpoint::queue_song),
post(crate::callers::song::endpoint::queue_song).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUESONG,
patch(crate::callers::song::endpoint::update_song_queue_status),
patch(crate::callers::song::endpoint::update_song_queue_status).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUESONGLINKUSERID,
patch(crate::callers::song::endpoint::link_user_id),
patch(crate::callers::song::endpoint::link_user_id).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUESONGDATA,
get(crate::callers::song::endpoint::download_flac),
get(crate::callers::song::endpoint::download_flac).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::NEXTQUEUESONG,
get(crate::callers::song::endpoint::fetch_queue_song),
get(crate::callers::song::endpoint::fetch_queue_song).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUESONGUPDATE,
patch(crate::callers::song::endpoint::update_song_queue),
patch(crate::callers::song::endpoint::update_song_queue).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUESONGDATAWIPE,
patch(crate::callers::song::endpoint::wipe_data_from_song_queue),
patch(crate::callers::song::endpoint::wipe_data_from_song_queue).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUEMETADATA,
post(crate::callers::metadata::endpoint::queue_metadata),
post(crate::callers::metadata::endpoint::queue_metadata).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUEMETADATA,
get(crate::callers::metadata::endpoint::fetch_metadata),
get(crate::callers::metadata::endpoint::fetch_metadata).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUECOVERART,
post(crate::callers::coverart::endpoint::queue),
post(crate::callers::coverart::endpoint::queue).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUECOVERARTDATA,
get(crate::callers::coverart::endpoint::fetch_coverart_with_data),
get(crate::callers::coverart::endpoint::fetch_coverart_with_data).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUECOVERART,
get(crate::callers::coverart::endpoint::fetch_coverart_no_data),
get(crate::callers::coverart::endpoint::fetch_coverart_no_data).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUECOVERARTLINK,
patch(crate::callers::coverart::endpoint::link),
patch(crate::callers::coverart::endpoint::link).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::QUEUECOVERARTDATAWIPE,
patch(crate::callers::coverart::endpoint::wipe_data_from_coverart_queue),
patch(crate::callers::coverart::endpoint::wipe_data_from_coverart_queue)
.route_layer(axum::middleware::from_fn(
crate::auth::auth::<axum::body::Body>,
)),
)
.route(
crate::callers::endpoints::CREATESONG,
post(crate::callers::song::endpoint::create_metadata),
post(crate::callers::song::endpoint::create_metadata).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::CREATECOVERART,
post(crate::callers::coverart::endpoint::create_coverart),
post(crate::callers::coverart::endpoint::create_coverart).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::GETSONGS,
get(crate::callers::song::endpoint::get_songs).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::GETCOVERART,
get(crate::callers::coverart::endpoint::get_coverart).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::DOWNLOADCOVERART,
get(crate::callers::coverart::endpoint::download_coverart).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::STREAMSONG,
get(crate::callers::song::endpoint::stream_song).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::DOWNLOADSONG,
get(crate::callers::song::endpoint::download_song).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.route(
crate::callers::endpoints::DELETESONG,
delete(crate::callers::song::endpoint::delete_song).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
}
@@ -121,13 +196,21 @@ pub mod init {
let pool = crate::db::create_pool()
.await
.expect("Failed to create pool");
// TODO: Look into handling this. Seems redundant to run migrations multiple times
crate::db::migrations(&pool).await;
let cors = tower_http::cors::CorsLayer::new()
.allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
routes()
.await
.layer(axum::Extension(pool))
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024))
.layer(TimeoutLayer::new(Duration::from_secs(300)))
.layer(cors)
}
}
@@ -227,6 +310,15 @@ mod tests {
Err("Error parsing".into())
}
}
pub async fn migrations(pool: &sqlx::PgPool) {
// Run migrations using the sqlx::migrate! macro
// Assumes your test migrations are in a ./test_migrations folder relative to Cargo.toml
sqlx::migrate!("./test_migrations")
.run(pool)
.await
.expect("Failed to run migrations");
}
}
mod init {
@@ -241,13 +333,43 @@ mod tests {
}
}
pub fn token_fields() -> (String, String, String) {
(
String::from("What a twist!"),
String::from("icarus_test"),
String::from("icarus"),
)
}
pub async fn test_token() -> Result<String, josekit::JoseError> {
let key: String = icarus_envy::environment::get_secret_main_key().await;
let (message, issuer, audience) = token_fields();
match icarus_models::token::create_token(&key, &message, &issuer, &audience) {
Ok((access_token, _some_time)) => Ok(access_token),
Err(err) => Err(err),
}
}
pub async fn bearer_auth() -> String {
let token = match test_token().await {
Ok(access_token) => access_token,
Err(err) => {
assert!(false, "Error: {err:?}");
String::new()
}
};
format!("Bearer {token}")
}
// TODO: Put the *_req() functions in their own module
async fn song_queue_req(
app: &axum::Router,
) -> Result<axum::response::Response, std::convert::Infallible> {
// Create multipart form
let mut form = MultipartForm::default();
let _ = form.add_file("flac", "tests/Machine_gun/track01.flac");
let _ = form.add_file("flac", "tests/I/track01.flac");
// Create request
let content_type = form.content_type();
@@ -256,6 +378,7 @@ mod tests {
.method(axum::http::Method::POST)
.uri(crate::callers::endpoints::QUEUESONG)
.header(axum::http::header::CONTENT_TYPE, content_type)
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from_stream(body))
.unwrap();
app.clone().oneshot(req).await
@@ -275,6 +398,7 @@ mod tests {
.method(axum::http::Method::PATCH)
.uri(crate::callers::endpoints::QUEUESONGLINKUSERID)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
@@ -288,6 +412,7 @@ mod tests {
.method(axum::http::Method::GET)
.uri(crate::callers::endpoints::NEXTQUEUESONG)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::empty())
.unwrap();
app.clone().oneshot(fetch_req).await
@@ -303,6 +428,7 @@ mod tests {
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::empty())
.unwrap();
@@ -321,6 +447,7 @@ mod tests {
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "audio/flac")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::empty())
.unwrap();
@@ -331,7 +458,7 @@ mod tests {
app: &axum::Router,
) -> Result<axum::response::Response, std::convert::Infallible> {
let mut form = MultipartForm::default();
let _ = form.add_file("jpg", "tests/Machine_gun/160809_machinegun.jpg");
let _ = form.add_file("jpg", "tests/I/Coverart-1.jpg");
// Create request
let content_type = form.content_type();
@@ -341,6 +468,7 @@ mod tests {
.method(axum::http::Method::POST)
.uri(crate::callers::endpoints::QUEUECOVERART)
.header(axum::http::header::CONTENT_TYPE, content_type)
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from_stream(body))
.unwrap();
@@ -358,6 +486,7 @@ mod tests {
.method(axum::http::Method::POST)
.uri(crate::callers::endpoints::QUEUEMETADATA)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
@@ -378,6 +507,7 @@ mod tests {
.method(axum::http::Method::PATCH)
.uri(crate::callers::endpoints::QUEUECOVERARTLINK)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
@@ -397,6 +527,7 @@ mod tests {
.method(axum::http::Method::POST)
.uri(crate::callers::endpoints::CREATECOVERART)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
app.clone().oneshot(req).await
@@ -407,27 +538,13 @@ mod tests {
song_queue_id: &uuid::Uuid,
user_id: &uuid::Uuid,
) -> Result<axum::response::Response, std::convert::Infallible> {
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": user_id,
"song_queue_id": song_queue_id
});
let payload = payload_data::create_song(song_queue_id, user_id).await;
let req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri(crate::callers::endpoints::CREATESONG)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
@@ -447,6 +564,7 @@ mod tests {
.method(axum::http::Method::PATCH)
.uri(crate::callers::endpoints::QUEUESONG)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
@@ -467,6 +585,7 @@ mod tests {
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::empty())
.unwrap();
@@ -647,23 +766,50 @@ mod tests {
serde_json::from_slice(&body).unwrap()
}
pub async fn format_url_with_value(endpoint: &str, value: &uuid::Uuid) -> String {
let last = endpoint.len() - 5;
format!("{}/{value}", &endpoint[0..last])
}
// TODO: Change the name of the function to be more expressive and put into it's own module
pub mod payload_data {
pub async fn queue_metadata_payload_data(song_queue_id: &uuid::Uuid) -> serde_json::Value {
serde_json::json!(
{
"song_queue_id": song_queue_id,
"album" : "Machine Gun: The FillMore East First Show",
"album_artist" : "Jimi Hendrix",
"artist" : "Jimi Hendrix",
"album" : "I",
"album_artist" : "Kuoth",
"artist" : "Kuoth",
"disc" : 1,
"disc_count" : 1,
"duration" : 330,
"genre" : "Psychadelic Rock",
"title" : "Power of Soul",
"duration" : 139,
"genre" : "Alternative Hip-Hop",
"title" : "Hypocrite Like The Rest",
"track" : 1,
"track_count" : 11,
"year" : 2016
"track_count" : 9,
"year" : 2020
})
}
pub async fn create_song(
song_queue_id: &uuid::Uuid,
user_id: &uuid::Uuid,
) -> serde_json::Value {
serde_json::json!({
"title" : "Hypocrite Like The Rest",
"artist" : "Kuoth",
"album_artist": "Kuoth",
"album": "I",
"genre" : "Alternative Hip-Hop",
"date": "2020-01-01",
"track": 1,
"disc": 1,
"track_count": 9,
"disc_count": 1,
"duration": 139,
"audio_type": "flac",
"user_id": user_id,
"song_queue_id": song_queue_id
})
}
}
@@ -904,6 +1050,10 @@ mod tests {
.method(axum::http::Method::PATCH)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, content_type)
.header(
axum::http::header::AUTHORIZATION,
bearer_auth().await,
)
.body(axum::body::Body::from_stream(body))
.unwrap(),
)
@@ -1366,6 +1516,10 @@ mod tests {
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "image/jpeg")
.header(
axum::http::header::AUTHORIZATION,
bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
@@ -1634,6 +1788,7 @@ mod tests {
.method(axum::http::Method::PATCH)
.uri(crate::callers::endpoints::QUEUESONGDATAWIPE)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(axum::http::header::AUTHORIZATION, bearer_auth().await)
.body(axum::body::Body::from(payload.to_string()))
.unwrap(),
)
@@ -1754,6 +1909,10 @@ mod tests {
axum::http::header::CONTENT_TYPE,
"application/json",
)
.header(
axum::http::header::AUTHORIZATION,
bearer_auth().await,
)
.body(axum::body::Body::from(payload.to_string()))
.unwrap(),
)
@@ -1792,4 +1951,489 @@ mod tests {
let _ = db_mgr::drop_database(&tm_pool, &db_name).await;
}
pub mod zzz_after_song_queue {
use futures::StreamExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_get_songs() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let (id, _, _, _) = test_data::song_id().await.unwrap();
let uri = format!("{}?id={id}", crate::callers::endpoints::GETSONGS);
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let resp = super::get_resp_data::<
crate::callers::song::response::get_songs::Response,
>(response)
.await;
assert_eq!(false, resp.data.is_empty(), "Should not be empty");
let song = resp.data[0].clone();
assert_eq!(id, song.id, "Id does not match {song:?}");
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
#[tokio::test]
async fn test_get_coverart() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let id = test_data::coverart_id().await.unwrap();
let uri = format!("{}?id={id}", crate::callers::endpoints::GETCOVERART);
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::GET)
.uri(uri)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let resp = super::get_resp_data::<
crate::callers::coverart::response::get_coverart::Response,
>(response)
.await;
assert_eq!(false, resp.data.is_empty(), "Should not be empty");
let coverart = resp.data[0].clone();
assert_eq!(id, coverart.id, "Id does not match {coverart:?}");
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
pub mod test_data {
pub async fn song_id() -> Result<(uuid::Uuid, String, String, String), uuid::Error> {
match uuid::Uuid::parse_str("44cf7940-34ff-489f-9124-d0ec90a55af9") {
Ok(id) => Ok((
id,
String::from("tests/I/"),
String::from("track01.flac"),
String::from("tests/I/Coverart-1.jpg"),
)),
Err(err) => Err(err),
}
}
pub async fn other_song_id() -> Result<(uuid::Uuid, String, String, String), uuid::Error>
{
match uuid::Uuid::parse_str("94cf7940-34ff-489f-9124-d0ec90a55af4") {
Ok(id) => Ok((
id,
String::from("tests/I/"),
String::from("track02.flac"),
String::from("tests/I/Coverart-2.jpg"),
)),
Err(err) => Err(err),
}
}
pub async fn coverart_id() -> Result<uuid::Uuid, uuid::Error> {
uuid::Uuid::parse_str("996122cd-5ae9-4013-9934-60768d3006ed")
}
}
#[tokio::test]
async fn test_stream_song() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let (id, _, _, _) = test_data::song_id().await.unwrap();
let my_url = crate::callers::endpoints::STREAMSONG;
let last = my_url.len() - 5;
let uri = format!("{}/{id}", &my_url[0..last]);
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::GET)
.uri(&uri)
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let e = response.into_body();
let mut data = e.into_data_stream();
while let Some(chunk) = data.next().await {
match chunk {
Ok(_data) => {}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
}
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
#[tokio::test]
async fn test_download_song() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let (id, _, _, _) = test_data::song_id().await.unwrap();
let uri =
super::format_url_with_value(crate::callers::endpoints::DOWNLOADSONG, &id).await;
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::GET)
.uri(&uri)
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let e = response.into_body();
let mut data = e.into_data_stream();
while let Some(chunk) = data.next().await {
match chunk {
Ok(_data) => {}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
}
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
#[tokio::test]
async fn test_download_coverart() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let id = test_data::coverart_id().await.unwrap();
let uri =
super::format_url_with_value(crate::callers::endpoints::DOWNLOADCOVERART, &id)
.await;
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::GET)
.uri(&uri)
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let e = response.into_body();
let mut data = e.into_data_stream();
while let Some(chunk) = data.next().await {
match chunk {
Ok(_data) => {}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
}
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
async fn get_test_data(
song_directory: &String,
song_filename: &String,
coverart_path: &String,
) -> Result<(Vec<u8>, Vec<u8>), std::io::Error> {
let song = icarus_models::song::Song {
directory: song_directory.clone(),
filename: song_filename.clone(),
..Default::default()
};
let coverart = icarus_models::coverart::CoverArt {
path: coverart_path.clone(),
..Default::default()
};
match song.to_data() {
Ok(song_data) => match coverart.to_data() {
Ok(coverart_data) => Ok((song_data, coverart_data)),
Err(err) => Err(err),
},
Err(err) => Err(err),
}
}
async fn save_test_again(
song_directory: &String,
song_filename: &String,
song_data: Vec<u8>,
coverart_path: &String,
coverart_data: Vec<u8>,
) -> Result<(), std::io::Error> {
let song = icarus_models::song::Song {
directory: song_directory.clone(),
filename: song_filename.clone(),
..Default::default()
};
let coverart = icarus_models::coverart::CoverArt {
path: coverart_path.clone(),
..Default::default()
};
use std::io::Write;
match song.song_path() {
Ok(song_path) => {
let song_p = std::path::Path::new(&song_path);
match std::fs::File::create(song_p) {
Ok(mut song_file) => match song_file.write_all(&song_data) {
Ok(_) => {}
Err(err) => {
return Err(err);
}
},
Err(err) => {
return Err(err);
}
}
}
Err(err) => {
return Err(err);
}
}
let coverart_p = std::path::Path::new(&coverart.path);
match std::fs::File::create(coverart_p) {
Ok(mut coverart_file) => match coverart_file.write_all(&coverart_data) {
Ok(_) => Ok(()),
Err(err) => Err(err),
},
Err(err) => Err(err),
}
}
#[tokio::test]
async fn test_last_delete_song() {
let tm_pool = super::db_mgr::get_pool().await.unwrap();
let db_name = super::db_mgr::generate_db_name().await;
match super::db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = super::db_mgr::connect_to_db(&db_name).await.unwrap();
super::db_mgr::migrations(&pool).await;
let app = super::init::app(pool).await;
let (id, song_directory, song_filename, coverart_path) =
test_data::other_song_id().await.unwrap();
let (song_data, coverart_data) =
get_test_data(&song_directory, &song_filename, &coverart_path)
.await
.unwrap();
let uri =
super::format_url_with_value(crate::callers::endpoints::DELETESONG, &id).await;
match app
.clone()
.oneshot(
axum::http::Request::builder()
.method(axum::http::Method::DELETE)
.uri(&uri)
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
{
Ok(response) => {
let resp = super::get_resp_data::<
crate::callers::song::response::delete_song::Response,
>(response)
.await;
assert_eq!(false, resp.data.is_empty(), "Response has no data");
let song_and_coverart = &resp.data[0];
assert_eq!(
id, song_and_coverart.song.id,
"Song Ids do not match {id:?} {:?}",
song_and_coverart.song.id
);
match save_test_again(
&song_directory,
&song_filename,
song_data,
&coverart_path,
coverart_data,
)
.await
{
Ok(_) => {}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
}
Err(err) => {
assert!(false, "Error: {err:?}");
}
}
let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
-- Add migration script here
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Table to store queued songs to process
CREATE TABLE IF NOT EXISTS "songQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
status TEXT CHECK (status IN ('pending', 'ready', 'processing', 'done')),
data BYTEA NULL,
user_id UUID NULL
);
-- Table to store queued metadata
CREATE TABLE IF NOT EXISTS "metadataQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
metadata jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
song_queue_id UUID NOT NULL
);
-- Table to store queued coverart
CREATE TABLE IF NOT EXISTS "coverartQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
data BYTEA NULL,
song_queue_id UUID NULL
);
-- 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,
year INT NOT NULL,
track INT NOT NULL,
disc INT NOT NULL,
track_count INT NOT NULL,
disc_count INT 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
);
CREATE TABLE IF NOT EXISTS "coverart" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
path TEXT NOT NULL,
song_id UUID NOT NULL
);
@@ -0,0 +1,6 @@
-- Add migration script here
INSERT INTO "song" (id, title, artist, album_artist, album, genre, year, track, disc, track_count, disc_count, duration, audio_type, date_created, filename, directory, user_id) VALUES('44cf7940-34ff-489f-9124-d0ec90a55af9', 'Hypocrite Like The Rest', 'Kuoth', 'Kuoth', 'I', 'Alternative Hip-Hop', 2020, 1, 1, 9, 1, 139, 'flac', '2020-01-01 13:00:00-05', 'track01.flac', 'tests/I', '47491f9b-725a-4ba4-b9a5-711e1be46670');
INSERT INTO "coverart" VALUES('996122cd-5ae9-4013-9934-60768d3006ed', 'I', 'tests/I/Coverart-1.jpg', '44cf7940-34ff-489f-9124-d0ec90a55af9');
INSERT INTO "song" (id, title, artist, album_artist, album, genre, year, track, disc, track_count, disc_count, duration, audio_type, date_created, filename, directory, user_id) VALUES('94cf7940-34ff-489f-9124-d0ec90a55af4', 'It''s Too Late', 'Kuoth', 'Kuoth', 'I', 'Alternative Hip-Hop', 2020, 2, 1, 9, 1, 116, 'flac', '2020-01-01 13:01:00-05', 'track02.flac', 'tests/I', '47491f9b-725a-4ba4-b9a5-711e1be46670');
INSERT INTO "coverart" VALUES('d96122cd-5ae9-4013-9934-60768d3006e9', 'I', 'tests/I/Coverart-2.jpg', '94cf7940-34ff-489f-9124-d0ec90a55af4');
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

+73
View File
@@ -0,0 +1,73 @@
{
"album": "I",
"album_artist": "Kuoth",
"disc_count": 1,
"genre": "Alternative Hip-Hop",
"year": 2020,
"track_count": 9,
"tracks": [
{
"artist": "Kuoth",
"disc": 1,
"title": "Hypocrite Like the Rest",
"duration": 139,
"track": 1
},
{
"artist": "Kuoth",
"disc": 1,
"title": "It's Too Late",
"duration": 116,
"track": 2
},
{
"artist": "Kuoth",
"disc": 1,
"title": "Hell Might be Nice",
"duration": 229,
"track": 3
},
{
"artist": "Kuoth",
"disc": 1,
"title": "Dark Side of Progress",
"duration": 187,
"track": 4
},
{
"artist": "Kuoth",
"disc": 1,
"title": "Distractions",
"duration": 141,
"track": 5
},
{
"artist": "Kuoth",
"disc": 1,
"title": "But You Won't Listen",
"duration": 184,
"track": 6
},
{
"artist": "Kuoth",
"disc": 1,
"title": "Guidance",
"duration": 121,
"track": 7
},
{
"artist": "Kuoth",
"disc": 1,
"title": "M",
"duration": 98,
"track": 8
},
{
"artist": "Kuoth",
"disc": 1,
"title": "Social Acceptance",
"duration": 169,
"track": 9
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB