diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 74b4171..711b72c 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index eeb8c42..756d04b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,36 @@ 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 = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atoi" version = "2.0.0" @@ -93,6 +117,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 +155,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 +275,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" @@ -323,6 +387,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -421,6 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -588,8 +664,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]] @@ -762,19 +840,24 @@ dependencies = [ [[package]] name = "icarus" -version = "0.1.96" +version = "0.2.0" 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", @@ -782,13 +865,15 @@ dependencies = [ "tower-http", "tracing-subscriber", "url", + "utoipa", + "utoipa-swagger-ui", "uuid", ] [[package]] name = "icarus_envy" -version = "0.3.0" -source = "git+ssh://git@git.kundeng.us/phoenix/icarus_envy.git?tag=v0.3.0-devel-d73fba9899-006#d73fba9899372b0655a90cb426645930135152da" +version = "0.3.2" +source = "git+ssh://git@git.kundeng.us/phoenix/icarus_envy.git?tag=v0.3.2#d84a8144aedf02e1b459d67c4023a7e0833f89fd" dependencies = [ "const_format", "dotenvy", @@ -797,20 +882,22 @@ dependencies = [ [[package]] name = "icarus_meta" version = "0.3.0" -source = "git+ssh://git@git.kundeng.us/phoenix/icarus_meta.git?tag=v0.3.0-devel-f4b71de969-680#f4b71de9692029a706b1ce82c39f6715c560158e" +source = "git+ssh://git@git.kundeng.us/phoenix/icarus_meta.git?tag=v0.3.0#f87c049ab3139995230485b33038a0425f2e7430" dependencies = [ "lofty", ] [[package]] name = "icarus_models" -version = "0.5.1" -source = "git+ssh://git@git.kundeng.us/phoenix/icarus_models.git?tag=v0.5.1-devel-1c5de9dc26-111#1c5de9dc26099ac5f21e1dc7a1f7d0dbb892b34b" +version = "0.5.6" +source = "git+ssh://git@git.kundeng.us/phoenix/icarus_models.git?tag=v0.5.6#2d6b550ae6721b41ecc3039799f6a5e873869077" dependencies = [ + "josekit", "rand 0.9.1", "serde", "serde_json", "time", + "utoipa", "uuid", ] @@ -929,6 +1016,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -937,6 +1025,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" @@ -947,6 +1052,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" @@ -978,6 +1098,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1134,6 +1263,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" @@ -1296,6 +1435,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" @@ -1466,6 +1615,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" @@ -1486,6 +1678,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1517,6 +1743,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -1581,6 +1816,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -1665,6 +1901,24 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[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" @@ -1728,7 +1982,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", @@ -1804,7 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -1848,7 +2102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -2279,6 +2533,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" @@ -2296,6 +2556,49 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.17.0" @@ -2326,6 +2629,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2431,6 +2744,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2776,3 +3098,35 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 456f330..b1891d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,33 @@ [package] name = "icarus" -version = "0.1.96" +version = "0.2.0" 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", features = ["full"] } tokio = { version = "1.45.1", features = ["full"] } tokio-util = { version = "0.7.15", features = ["io"] } -tower-http = { version = "0.6.6", features = ["timeout"] } +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"] } -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.5.1-devel-1c5de9dc26-111" } -icarus_envy = { git = "ssh://git@git.kundeng.us/phoenix/icarus_envy.git", tag = "v0.3.0-devel-d73fba9899-006" } +thiserror = "1.0" +base64 = "0.21" +jsonwebtoken = { version = "9.3.1" } +josekit = { version = "0.10.1" } +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } +icarus_meta = { git = "ssh://git@git.kundeng.us/phoenix/icarus_meta.git", tag = "v0.3.0" } +icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.5.6" } +icarus_envy = { git = "ssh://git@git.kundeng.us/phoenix/icarus_envy.git", tag = "v0.3.2" } [dev-dependencies] common-multipart-rfc7578 = { version = "0.7.0" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5a66d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Kun Deng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ca22c83..58298aa 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,25 @@ +# Icarus +Web API for the Icarus project. -# Getting Started -## Docker -Make sure `icarus_auth` is located in the root of the parent directory if using docker. +## Getting Started +Quickest way to get started is with docker. Make sure `icarus_auth` and `songparser` repositories +are located in the root of the parent directory. Check the respective repositories to ensure they +are setup correctly before configuring `Icarus`. -Create a `.env` file for both projects - `icarus_auth` and `icarus` - in the root of each project. +Copy the `.env.docker.sample` file to `.env`. Ensure that the `ROOT_DIRECTORY` variable is populated +and exists on the docker image's filesystem. The credentials for the database doesn't need to be +changed for development, but if deploying it, it should be modified. Build containers ``` -docker compose build --ssh default api auth_api +docker compose build --ssh default ``` Bring it up ``` -docker compose up -d --force-recreate api auth_api +docker compose up -d --force-recreate ``` + +To view the OpenAPI spec, run the project and access `/swagger-ui`. If running through docker, +the url would be something like `http://localhost:8000/swagger-ui`. diff --git a/docker-compose.yaml b/docker-compose.yaml index 46c6f3b..7a952eb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,7 +17,7 @@ services: main_db: condition: service_healthy networks: - - main-api-network + - icarus-network restart: unless-stopped # Optional: Restart policy # --- Web API auth --- @@ -39,11 +39,30 @@ services: auth_db: condition: service_healthy networks: - - auth-api-network + - icarus-network # Optional: Mount local code for development (live reload) # volumes: # - ./path/to/your/web-api-repo:/app + # --- songparser service --- + songparser: + build: + context: ../songparser + ssh: ["default"] + dockerfile: Dockerfile + container_name: songparser + restart: unless-stopped + env_file: + - ../songparser/.env + depends_on: + - api + - main_db + - auth_api + - auth_db + networks: + - icarus-network + + # PostgreSQL Database Service # --- icarus web api db --- main_db: @@ -69,7 +88,7 @@ services: start_period: 10s restart: always # Optional: Restart policy networks: - - main-api-network + - icarus-network # --- icarus web auth api db --- auth_db: @@ -95,7 +114,7 @@ services: start_period: 10s restart: always # Optional: Restart policy networks: - - auth-api-network + - icarus-network # Define the named volume for data persistence volumes: @@ -106,7 +125,5 @@ volumes: # Define the network (optional, but good practice) networks: - main-api-network: - driver: bridge - auth-api-network: + icarus-network: driver: bridge diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..f3bf405 --- /dev/null +++ b/src/auth.rs @@ -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 +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>, // 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( + cookie_jar: CookieJar, + req: Request, + next: Next, +) -> Result)> { + 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::( + &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) +} diff --git a/src/callers/coverart.rs b/src/callers/coverart.rs index 7a3398a..b386c1b 100644 --- a/src/callers/coverart.rs +++ b/src/callers/coverart.rs @@ -1,14 +1,26 @@ // TODO: Separate queue and coverart endpoints -#[derive(Debug, Default, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct CoverArtQueue { pub id: uuid::Uuid, pub song_queue_id: uuid::Uuid, } pub mod request { + pub mod queue { + #[derive(utoipa::ToSchema)] + pub struct Request { + /// Filename + pub file: String, + #[schema(rename = "type")] + /// File type. Should be a file and not a value + pub file_type: String, + /// Raw data of the cover art file + pub value: Vec, + } + } pub mod link { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub coverart_id: uuid::Uuid, pub song_queue_id: uuid::Uuid, @@ -16,7 +28,7 @@ pub mod request { } pub mod fetch_coverart_no_data { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Params { pub id: Option, pub song_queue_id: Option, @@ -24,7 +36,7 @@ pub mod request { } pub mod fetch_coverart_with_data { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Params { pub id: Option, pub song_queue_id: Option, @@ -32,7 +44,7 @@ pub mod request { } pub mod create_coverart { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub song_id: uuid::Uuid, pub coverart_queue_id: uuid::Uuid, @@ -40,14 +52,14 @@ pub mod request { } pub mod wipe_data_from_coverart_queue { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub coverart_queue_id: uuid::Uuid, } } pub mod get_coverart { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Params { pub id: Option, } @@ -55,20 +67,20 @@ pub mod request { } pub mod response { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, } pub mod link { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Id { pub coverart_id: uuid::Uuid, pub song_queue_id: uuid::Uuid, } - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -76,7 +88,7 @@ pub mod response { } pub mod fetch_coverart_no_data { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -84,7 +96,7 @@ pub mod response { } pub mod fetch_coverart_with_data { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec>, @@ -92,7 +104,7 @@ pub mod response { } pub mod create_coverart { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -100,7 +112,7 @@ pub mod response { } pub mod wipe_data_from_coverart_queue { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -108,7 +120,7 @@ pub mod response { } pub mod get_coverart { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -376,6 +388,88 @@ pub mod cov_db { Err(_) => Err(sqlx::Error::RowNotFound), } } + + pub async fn get_coverart_with_song_id( + pool: &sqlx::PgPool, + song_id: &uuid::Uuid, + ) -> Result { + 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 { + 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 { @@ -383,6 +477,18 @@ pub mod endpoint { use axum::response::IntoResponse; + /// Endpoint to queue cover art + #[utoipa::path( + post, + path = super::super::endpoints::QUEUECOVERART, + request_body( + content = super::request::queue::Request, + ), + responses( + (status = 200, description = "Successful", body = super::response::Response), + (status = 400, description = "Error queueing cover art", body = super::response::Response) + ) + )] pub async fn queue( axum::Extension(pool): axum::Extension, mut multipart: axum::extract::Multipart, @@ -428,6 +534,20 @@ pub mod endpoint { } } + /// Endpoint to link queued cover art + #[utoipa::path( + post, + path = super::super::endpoints::QUEUECOVERARTLINK, + request_body( + content = super::request::link::Request, + description = "Linking queued cover art to queued song", + content_type = "application/json" + ), + responses( + (status = 200, description = "Queued cover art linked", body = super::response::link::Response), + (status = 400, description = "Linkage failed", body = super::response::link::Response) + ) + )] pub async fn link( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -455,6 +575,19 @@ pub mod endpoint { } } + /// Endpoint to fetch cover art details + #[utoipa::path( + get, + path = super::super::endpoints::QUEUECOVERART, + params( + ("id" = uuid::Uuid, Path, description = "Queued cover art Id"), + ("song_queue_id" = uuid::Uuid, Path, description = "Queued song Id") + ), + responses( + (status = 200, description = "Queued song linked", body = super::response::fetch_coverart_no_data::Response), + (status = 400, description = "Linkage failed", body = super::response::fetch_coverart_no_data::Response) + ) + )] pub async fn fetch_coverart_no_data( axum::Extension(pool): axum::Extension, axum::extract::Query(params): axum::extract::Query< @@ -502,6 +635,16 @@ pub mod endpoint { } } + /// Endpoint to fetch the queued cover art data + #[utoipa::path( + get, + path = super::super::endpoints::QUEUECOVERARTDATA, + params(("id" = uuid::Uuid, Path, description = "Queued cover art Id")), + responses( + (status = 200, description = "Queued cover art data", body = Vec), + (status = 400, description = "Error fetching queued cover art data", body = Vec) + ) + )] pub async fn fetch_coverart_with_data( axum::Extension(pool): axum::Extension, axum::extract::Path(id): axum::extract::Path, @@ -530,6 +673,20 @@ pub mod endpoint { } } + /// Endpoint to create cover art + #[utoipa::path( + post, + path = super::super::endpoints::CREATECOVERART, + request_body( + content = super::request::create_coverart::Request, + description = "Data required to create cover art", + content_type = "application/json" + ), + responses( + (status = 200, description = "Cover art created", body = super::response::create_coverart::Response), + (status = 400, description = "Failure in creating cover art", body = super::response::create_coverart::Response) + ) + )] pub async fn create_coverart( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -590,6 +747,21 @@ pub mod endpoint { } } + /// Endpoint to wipe data from the cover art queue + #[utoipa::path( + patch, + path = super::super::endpoints::QUEUECOVERARTDATAWIPE, + request_body( + content = super::request::wipe_data_from_coverart_queue::Request, + description = "Data required to wipe the data from the cover art queue", + content_type = "application/json" + ), + responses( + (status = 200, description = "Data wiped from cover art queue", body = super::response::wipe_data_from_coverart_queue::Response), + (status = 400, description = "Error wiping the data", body = super::response::wipe_data_from_coverart_queue::Response), + (status = 404, description = "Cover art not found", body = super::response::wipe_data_from_coverart_queue::Response) + ) + )] pub async fn wipe_data_from_coverart_queue( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -619,6 +791,18 @@ pub mod endpoint { } } + /// Endpoint to get cover art with criteria + #[utoipa::path( + get, + path = super::super::endpoints::GETCOVERART, + params( + ("id" = uuid::Uuid, Path, description = "Cover art Id") + ), + responses( + (status = 200, description = "Cover art retrieved", body = super::response::get_coverart::Response), + (status = 400, description = "Error retrieving cover art", body = super::response::get_coverart::Response) + ) + )] pub async fn get_coverart( axum::Extension(pool): axum::Extension, axum::extract::Query(params): axum::extract::Query, @@ -646,4 +830,52 @@ pub mod endpoint { } } } + + /// Endpoint to download cover art + #[utoipa::path( + get, + path = super::super::endpoints::DOWNLOADCOVERART, + params( + ("id" = uuid::Uuid, Path, description = "Cover art Id") + ), + responses( + (status = 200, description = "Cover art downloading", body = Vec), + (status = 404, description = "Cover art not found", body = Vec) + ) + )] + pub async fn download_coverart( + axum::Extension(pool): axum::Extension, + axum::extract::Path(id): axum::extract::Path, + ) -> (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(), + ), + } + } } diff --git a/src/callers/metadata.rs b/src/callers/metadata.rs index 6825fbd..72f5d10 100644 --- a/src/callers/metadata.rs +++ b/src/callers/metadata.rs @@ -1,10 +1,9 @@ // TODO: Explicitly make this module target queueing a song's metadata pub mod request { - pub mod queue_metadata { use serde::{Deserialize, Serialize}; - #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow)] + #[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct Request { pub song_queue_id: uuid::Uuid, pub album: String, @@ -43,7 +42,13 @@ pub mod request { pub mod fetch_metadata { #[derive( - Debug, Default, serde::Deserialize, serde::Serialize, sqlx::FromRow, sqlx::Decode, + Debug, + Default, + serde::Deserialize, + serde::Serialize, + sqlx::FromRow, + sqlx::Decode, + utoipa::ToSchema, )] pub struct Params { pub id: Option, @@ -53,11 +58,10 @@ pub mod request { } pub mod response { - pub mod queue_metadata { use serde::{Deserialize, Serialize}; - #[derive(Default, Deserialize, Serialize)] + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -67,7 +71,7 @@ pub mod response { pub mod fetch_metadata { use serde::{Deserialize, Serialize}; - #[derive(Default, Deserialize, Serialize)] + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -83,7 +87,7 @@ pub mod metadata_queue { pub id: uuid::Uuid, } - #[derive(Debug, serde::Deserialize, serde::Serialize, sqlx::FromRow)] + #[derive(Debug, serde::Deserialize, serde::Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct MetadataQueue { pub id: uuid::Uuid, pub metadata: serde_json::Value, @@ -204,9 +208,24 @@ pub mod metadata_queue { } } +/// Module for metadata related endpoints pub mod endpoint { use axum::{Json, http::StatusCode}; + /// Endpoint to create queued metadata + #[utoipa::path( + post, + path = super::super::endpoints::QUEUEMETADATA, + request_body( + content = super::request::queue_metadata::Request, + description = "Data required to create queued metadata", + content_type = "application/json" + ), + responses( + (status = 200, description = "Queued metadata created", body = super::response::queue_metadata::Response), + (status = 400, description = "Error creating queued metadata", body = super::response::queue_metadata::Response) + ) + )] pub async fn queue_metadata( axum::Extension(pool): axum::Extension, Json(payload): Json, @@ -221,7 +240,7 @@ pub mod endpoint { response.message = if response.data.is_empty() { String::from("Error") } else { - String::from("Success") + String::from(super::super::response::SUCCESSFUL) }; (StatusCode::OK, Json(response)) @@ -233,6 +252,19 @@ pub mod endpoint { } } + /// Endpoint to get queued metadata + #[utoipa::path( + get, + path = super::super::endpoints::QUEUEMETADATA, + params( + ("id" = uuid::Uuid, Path, description = "Id of queued metadata"), + ("song_queue_id" = uuid::Uuid, Path, description = "Id of queued song") + ), + responses( + (status = 200, description = "Queued metadata retrieved", body = super::response::fetch_metadata::Response), + (status = 400, description = "Error retrieving queued metadata", body = super::response::fetch_metadata::Response) + ) + )] pub async fn fetch_metadata( axum::Extension(pool): axum::Extension, axum::extract::Query(params): axum::extract::Query, diff --git a/src/callers/mod.rs b/src/callers/mod.rs index b04f7a2..f19362a 100644 --- a/src/callers/mod.rs +++ b/src/callers/mod.rs @@ -17,9 +17,13 @@ pub mod endpoints { pub const CREATESONG: &str = "/api/v2/song"; pub const GETSONGS: &str = "/api/v2/song"; + pub const GETALLSONGS: &str = "/api/v2/song/all"; 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 { diff --git a/src/callers/song.rs b/src/callers/song.rs index ab175c3..2051fda 100644 --- a/src/callers/song.rs +++ b/src/callers/song.rs @@ -7,8 +7,21 @@ pub mod request { pub message: String, } + pub mod song_queue { + #[derive(utoipa::ToSchema)] + pub struct SongQueueRequest { + /// Filename + pub file: String, + #[schema(rename = "type")] + /// File type. Should be a file and not a value + pub file_type: String, + /// Raw data of the flac file + pub value: Vec, + } + } + pub mod update_status { - #[derive(Default, serde::Deserialize, serde::Serialize)] + #[derive(Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub id: uuid::Uuid, pub status: String, @@ -16,7 +29,7 @@ pub mod request { } pub mod create_metadata { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub title: String, pub artist: String, @@ -79,14 +92,14 @@ pub mod request { } pub mod wipe_data_from_song_queue { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub song_queue_id: uuid::Uuid, } } pub mod link_user_id { - #[derive(Debug, serde::Deserialize, serde::Serialize)] + #[derive(Debug, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Request { pub song_queue_id: uuid::Uuid, pub user_id: uuid::Uuid, @@ -94,7 +107,7 @@ pub mod request { } pub mod get_songs { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Params { pub id: Option, } @@ -104,16 +117,18 @@ pub mod request { pub mod response { use serde::{Deserialize, Serialize}; - #[derive(Default, Deserialize, Serialize)] + /// Song queue response + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, + /// Id of the queued song pub data: Vec, } pub mod fetch_queue_song { use serde::{Deserialize, Serialize}; - #[derive(Default, Deserialize, Serialize)] + #[derive(Default, Deserialize, Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -121,13 +136,13 @@ pub mod response { } pub mod update_status { - #[derive(serde::Deserialize, serde::Serialize)] + #[derive(serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct ChangedStatus { pub old_status: String, pub new_status: String, } - #[derive(Default, serde::Deserialize, serde::Serialize)] + #[derive(Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -135,7 +150,7 @@ pub mod response { } pub mod update_song_queue { - #[derive(Default, serde::Deserialize, serde::Serialize)] + #[derive(Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -143,7 +158,7 @@ pub mod response { } pub mod create_metadata { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -151,7 +166,7 @@ pub mod response { } pub mod wipe_data_from_song_queue { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -159,7 +174,7 @@ pub mod response { } pub mod link_user_id { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, @@ -167,12 +182,26 @@ pub mod response { } pub mod get_songs { - #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] pub struct Response { pub message: String, pub data: Vec, } } + + pub mod delete_song { + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] + pub struct SongAndCoverArt { + pub song: icarus_models::song::Song, + pub coverart: icarus_models::coverart::CoverArt, + } + + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] + 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 @@ -336,6 +365,207 @@ pub mod song_db { Err(_) => Err(sqlx::Error::RowNotFound), } } + + pub async fn get_all_songs( + pool: &sqlx::PgPool, + ) -> Result, sqlx::Error> { + let result = sqlx::query( + r#" + SELECT * FROM "song"; + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Error querying data: {e:?}"); + }); + + match result { + Ok(rows) => { + let mut songs: Vec = Vec::new(); + + for row in rows { + let date_created_time: time::OffsetDateTime = row + .try_get("date_created") + .map_err(|_e| sqlx::Error::RowNotFound) + .unwrap(); + + let song = 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(), + }; + + songs.push(song); + } + + Ok(songs) + } + Err(_err) => Err(sqlx::Error::RowNotFound), + } + } + + pub async fn delete_song( + pool: &sqlx::PgPool, + id: &uuid::Uuid, + ) -> Result { + 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 { @@ -346,7 +576,8 @@ mod song_queue { pub id: uuid::Uuid, } - #[derive(Debug, serde::Deserialize, serde::Serialize, sqlx::FromRow)] + // TODO: Move this somewhere else at some point + #[derive(Debug, serde::Deserialize, serde::Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct SongQueue { pub id: uuid::Uuid, pub filename: String, @@ -639,12 +870,27 @@ mod song_queue { } } +/// Module for song related endpoints pub mod endpoint { use axum::{Json, http::StatusCode, response::IntoResponse}; + use std::io::Write; use crate::callers::song::song_queue; + /// Endpoint to queue a song. Starts the process and places the song in a queue + #[utoipa::path( + post, + path = super::super::endpoints::QUEUESONG, + request_body( + content = super::request::song_queue::SongQueueRequest, + description = "Multipart form data for uploading song", + content_type = "multipart/form-data" + ), + responses( + (status = 200, description = "Song queued", body = super::response::Response) + ) + )] pub async fn queue_song( axum::Extension(pool): axum::Extension, mut multipart: axum::extract::Multipart, @@ -693,6 +939,20 @@ pub mod endpoint { (StatusCode::OK, Json(response)) } + /// Endpoint to link a user id to a queued song + #[utoipa::path( + patch, + path = super::super::endpoints::QUEUESONGLINKUSERID, + request_body( + content = super::request::link_user_id::Request, + description = "User Id and queued song id", + content_type = "application/json" + ), + responses( + (status = 200, description = "Queued song linked", body = super::response::link_user_id::Response), + (status = 400, description = "Linkage failed", body = super::response::link_user_id::Response) + ) + )] pub async fn link_user_id( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -724,6 +984,15 @@ pub mod endpoint { } } + /// Endpoint to fetch the next queued song as long as it is available + #[utoipa::path( + get, + path = super::super::endpoints::NEXTQUEUESONG, + responses( + (status = 200, description = "Queued song is present and available", body = super::response::fetch_queue_song::Response), + (status = 400, description = "Linkage failed", body = super::response::fetch_queue_song::Response) + ) + )] pub async fn fetch_queue_song( axum::Extension(pool): axum::Extension, ) -> ( @@ -746,6 +1015,16 @@ pub mod endpoint { } // TODO: Rename + /// Endpoint to download the queued song + #[utoipa::path( + get, + path = super::super::endpoints::QUEUESONGDATA, + params(("id" = uuid::Uuid, Path, description = "Queued song Id")), + responses( + (status = 200, description = "Queued song linked", body = Vec), + (status = 400, description = "Linkage failed", body = Vec) + ) + )] pub async fn download_flac( axum::Extension(pool): axum::Extension, axum::extract::Path(id): axum::extract::Path, @@ -774,6 +1053,20 @@ pub mod endpoint { } } + /// Endpoint to update the status of a queued song + #[utoipa::path( + patch, + path = super::super::endpoints::QUEUESONG, + request_body( + content = super::request::update_status::Request, + description = "Update the status of a queued song", + content_type = "application/json" + ), + responses( + (status = 200, description = "Status has been updated", body = super::response::update_status::Response), + (status = 400, description = "Error updating status of queued song", body = super::response::update_status::Response) + ) + )] pub async fn update_song_queue_status( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -826,6 +1119,22 @@ pub mod endpoint { } } + /// Endpoint to update the queued song data + #[utoipa::path( + patch, + path = super::super::endpoints::QUEUESONGUPDATE, + request_body( + content = super::request::song_queue::SongQueueRequest, + description = "Multipart form data for uploading song", + content_type = "multipart/form-data" + ), + params(("id" = uuid::Uuid, Path, description = "Queued song Id")), + responses( + (status = 200, description = "Queued song updated", body = super::response::update_song_queue::Response), + (status = 400, description = "Error updating queued song", body = super::response::update_song_queue::Response), + (status = 404, description = "Queued song not found", body = super::response::update_song_queue::Response) + ) + )] pub async fn update_song_queue( axum::extract::Path(id): axum::extract::Path, axum::Extension(pool): axum::Extension, @@ -872,6 +1181,21 @@ pub mod endpoint { } } + /// Endpoint to create song + #[utoipa::path( + post, + path = super::super::endpoints::QUEUEMETADATA, + request_body( + content = super::request::create_metadata::Request, + description = "Data needed to create the song and save it to the filesystem", + content_type = "application/json" + ), + responses( + (status = 200, description = "Song created", body = super::response::create_metadata::Response), + (status = 400, description = "Error", body = super::response::create_metadata::Response), + (status = 505, description = "Error creating song", body = super::response::create_metadata::Response) + ) + )] pub async fn create_metadata( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -955,6 +1279,20 @@ pub mod endpoint { } } + /// Endpoint to wipe the data from a queued song + #[utoipa::path( + patch, + path = super::super::endpoints::QUEUESONGDATAWIPE, + request_body( + content = super::request::wipe_data_from_song_queue::Request, + description = "Pass the queued song Id to wipe the data", + content_type = "application/json" + ), + responses( + (status = 200, description = "Queued song data wiped", body = super::response::wipe_data_from_song_queue::Response), + (status = 404, description = "Queued song cannot be found", body = super::response::wipe_data_from_song_queue::Response) + ) + )] pub async fn wipe_data_from_song_queue( axum::Extension(pool): axum::Extension, axum::Json(payload): axum::Json, @@ -985,6 +1323,18 @@ pub mod endpoint { } } + // Endpoint to get songs + #[utoipa::path( + get, + path = super::super::endpoints::GETSONGS, + params( + ("id" = uuid::Uuid, Path, description = "Id of song") + ), + responses( + (status = 200, description = "Songs found", body = super::response::get_songs::Response), + (status = 400, description = "Error getting songs", body = super::response::get_songs::Response) + ) + )] pub async fn get_songs( axum::Extension(pool): axum::Extension, axum::extract::Query(params): axum::extract::Query, @@ -1013,6 +1363,46 @@ pub mod endpoint { } } + /// Endpoint to get all songs + #[utoipa::path( + get, + path = super::super::endpoints::GETALLSONGS, + responses( + (status = 200, description = "Getting all songs", body = super::response::get_songs::Response), + (status = 404, description = "Song not found", body = super::response::get_songs::Response) + ) + )] + pub async fn get_all_songs( + axum::Extension(pool): axum::Extension, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + let mut response = super::response::get_songs::Response::default(); + + match super::song_db::get_all_songs(&pool).await { + Ok(songs) => { + response.message = String::from(super::super::response::SUCCESSFUL); + response.data = songs; + (axum::http::StatusCode::OK, axum::Json(response)) + } + Err(err) => { + response.message = err.to_string(); + (axum::http::StatusCode::NOT_FOUND, axum::Json(response)) + } + } + } + + /// Ednpoint to stream song + #[utoipa::path( + get, + path = super::super::endpoints::STREAMSONG, + params(("id" = uuid::Uuid, Path, description = "Song Id")), + responses( + (status = 200, description = "Stream song", body = Vec), + (status = 500, description = "Error streaming song", body = (u64, String)) + ) + )] pub async fn stream_song( axum::Extension(pool): axum::Extension, axum::extract::Path(id): axum::extract::Path, @@ -1059,4 +1449,158 @@ pub mod endpoint { )), } } + + /// Endpoint to download song + #[utoipa::path( + get, + path = super::super::endpoints::DOWNLOADSONG, + params(("id" = uuid::Uuid, Path, description = "Song Id")), + responses( + (status = 200, description = "Download song", body = (u64, Vec)), + (status = 404, description = "Song not found", body = (u64, Vec)), + (status = 400, description = "Error downloading song", body = (u64, Vec)) + ) + )] + pub async fn download_song( + axum::Extension(pool): axum::Extension, + axum::extract::Path(id): axum::extract::Path, + ) -> (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(), + ), + } + } + + /// Endpoint to delete the song + #[utoipa::path( + delete, + path = super::super::endpoints::DELETESONG, + params(("id" = uuid::Uuid, Path, description = "Song Id")), + responses( + (status = 200, description = "Song deleted", body = super::response::delete_song::Response), + (status = 404, description = "Song not found", body = super::response::delete_song::Response), + (status = 500, description = "Error deleting song", body = super::response::delete_song::Response) + ) + )] + pub async fn delete_song( + axum::Extension(pool): axum::Extension, + axum::extract::Path(id): axum::extract::Path, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + 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)) + } + } + } } diff --git a/src/main.rs b/src/main.rs index d1d99f7..369894f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod callers; pub mod db { @@ -44,88 +45,186 @@ 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 utoipa::OpenApi; + + use axum::http::{ + HeaderValue, Method, + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + }; + + use crate::callers::coverart as coverart_caller; + use crate::callers::metadata as metadata_caller; + use crate::callers::song as song_caller; + use coverart_caller::endpoint as coverart_endpoints; + use coverart_caller::response as coverart_responses; + use metadata_caller::endpoint as metadata_endpoints; + use metadata_caller::response as metadata_responses; + use song_caller::endpoint as song_endpoints; + use song_caller::response as song_responses; + + #[derive(utoipa::OpenApi)] + #[openapi( + paths(song_endpoints::queue_song, song_endpoints::link_user_id, song_endpoints::fetch_queue_song, song_endpoints::download_flac, + song_endpoints::update_song_queue_status, song_endpoints::update_song_queue, song_endpoints::create_metadata, song_endpoints::wipe_data_from_song_queue, song_endpoints::get_songs, song_endpoints::get_all_songs, song_endpoints::stream_song, song_endpoints::download_song, + song_endpoints::delete_song, coverart_endpoints::queue, coverart_endpoints::link, coverart_endpoints::fetch_coverart_no_data, + coverart_endpoints::fetch_coverart_with_data, coverart_endpoints::create_coverart, coverart_endpoints::wipe_data_from_coverart_queue, + coverart_endpoints::get_coverart, coverart_endpoints::download_coverart, + metadata_endpoints::queue_metadata, metadata_endpoints::fetch_metadata), + components(schemas(song_responses::Response, song_responses::link_user_id::Response, song_responses::fetch_queue_song::Response, + song_responses::update_status::Response, song_responses::update_song_queue::Response, song_responses::create_metadata::Response, + song_responses::wipe_data_from_song_queue::Response, song_responses::get_songs::Response, song_responses::delete_song::Response, + coverart_responses::Response, coverart_responses::link::Response, coverart_responses::fetch_coverart_no_data::Response, + coverart_responses::fetch_coverart_with_data::Response, coverart_responses::create_coverart::Response, + coverart_responses::wipe_data_from_coverart_queue::Response, coverart_responses::get_coverart::Response, + metadata_responses::queue_metadata::Response, metadata_responses::fetch_metadata::Response)), + tags( + (name = "Icarus API", description = "Web API to manage music") + ) + )] + struct ApiDoc; 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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::), + ), ) .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::, + )), ) .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::), + ), ) .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::), + ), ) .route( crate::callers::endpoints::GETSONGS, - get(crate::callers::song::endpoint::get_songs), + get(crate::callers::song::endpoint::get_songs).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), ) .route( crate::callers::endpoints::GETCOVERART, - get(crate::callers::coverart::endpoint::get_coverart), + get(crate::callers::coverart::endpoint::get_coverart).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), + ) + .route( + crate::callers::endpoints::DOWNLOADCOVERART, + get(crate::callers::coverart::endpoint::download_coverart).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), ) .route( crate::callers::endpoints::STREAMSONG, - get(crate::callers::song::endpoint::stream_song), + get(crate::callers::song::endpoint::stream_song).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), + ) + .route( + crate::callers::endpoints::DOWNLOADSONG, + get(crate::callers::song::endpoint::download_song).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), + ) + .route( + crate::callers::endpoints::DELETESONG, + delete(crate::callers::song::endpoint::delete_song).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), + ) + .route( + crate::callers::endpoints::GETALLSONGS, + get(crate::callers::song::endpoint::get_all_songs), ) } @@ -136,11 +235,22 @@ pub mod init { // 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::().unwrap()) + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE]) + .allow_credentials(true) + .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]); + routes() .await + .merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", ApiDoc::openapi()), + ) .layer(axum::Extension(pool)) .layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)) .layer(TimeoutLayer::new(Duration::from_secs(300))) + .layer(cors) } } @@ -263,6 +373,45 @@ mod tests { } } + pub fn token_fields() -> (String, String, String) { + ( + String::from("What a twist!"), + String::from("icarus_test"), + String::from("icarus"), + ) + } + + pub const TEST_USER_ID: uuid::Uuid = uuid::uuid!("cc938368-615a-4694-b2ca-6e122fa31c52"); + + pub async fn test_token() -> Result { + let key: String = icarus_envy::environment::get_secret_main_key().await; + let (message, issuer, audience) = token_fields(); + + let token_resource = icarus_models::token::TokenResource { + message: message, + issuer: issuer, + audiences: vec![audience], + id: TEST_USER_ID, + }; + + match icarus_models::token::create_token(&key, &token_resource, time::Duration::hours(1)) { + 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, @@ -278,6 +427,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 @@ -297,6 +447,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(); @@ -310,6 +461,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 @@ -325,6 +477,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(); @@ -343,6 +496,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(); @@ -353,7 +507,7 @@ mod tests { app: &axum::Router, ) -> Result { let mut form = MultipartForm::default(); - let _ = form.add_file("jpg", "tests/I/Coverart.jpg"); + let _ = form.add_file("jpg", "tests/I/Coverart-1.jpg"); // Create request let content_type = form.content_type(); @@ -363,6 +517,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(); @@ -380,6 +535,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(); @@ -400,6 +556,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(); @@ -419,6 +576,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 @@ -435,6 +593,7 @@ mod tests { .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(); @@ -454,6 +613,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(); @@ -474,6 +634,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(); @@ -495,9 +656,7 @@ mod tests { let song_queue_id = resp.data[0]; assert_eq!(false, song_queue_id.is_nil(), "Should not be empty"); - let user_id = uuid::Uuid::new_v4(); - - // match super::get_resp_data::(response).await { + let user_id = super::TEST_USER_ID; match super::song_queue_link_req(&app, &song_queue_id, &user_id).await { Ok(response) => { @@ -654,6 +813,11 @@ 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 { @@ -759,7 +923,7 @@ mod tests { assert_eq!(false, resp.data[0].is_nil(), "Should not be empty"); let song_queue_id = &resp.data[0]; - let user_id = uuid::Uuid::new_v4(); + let user_id = TEST_USER_ID; println!("User Id: {user_id:?}"); match song_queue_link_req(&app, &song_queue_id, &user_id).await { @@ -933,6 +1097,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(), ) @@ -1395,6 +1563,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(), ) @@ -1663,6 +1835,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(), ) @@ -1783,6 +1956,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(), ) @@ -1822,7 +1999,7 @@ mod tests { let _ = db_mgr::drop_database(&tm_pool, &db_name).await; } - pub mod after_song_queue { + pub mod zzz_after_song_queue { use futures::StreamExt; use tower::ServiceExt; @@ -1845,7 +2022,7 @@ mod tests { let app = super::init::app(pool).await; - let id = test_data::song_id().await.unwrap(); + let (id, _, _, _) = test_data::song_id().await.unwrap(); let uri = format!("{}?id={id}", crate::callers::endpoints::GETSONGS); @@ -1856,6 +2033,10 @@ mod tests { .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(), ) @@ -1909,6 +2090,10 @@ mod tests { .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(), ) @@ -1933,8 +2118,29 @@ mod tests { } pub mod test_data { - pub async fn song_id() -> Result { - uuid::Uuid::parse_str("44cf7940-34ff-489f-9124-d0ec90a55af9") + 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 { @@ -1961,7 +2167,7 @@ mod tests { let app = super::init::app(pool).await; - let id = test_data::song_id().await.unwrap(); + let (id, _, _, _) = test_data::song_id().await.unwrap(); let my_url = crate::callers::endpoints::STREAMSONG; let last = my_url.len() - 5; @@ -1973,6 +2179,10 @@ mod tests { 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(), ) @@ -1997,5 +2207,339 @@ mod tests { 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, Vec), 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, + coverart_path: &String, + coverart_data: Vec, + ) -> 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; + } + + #[tokio::test] + async fn test_get_all_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; + + match app + .clone() + .oneshot( + axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri(crate::callers::endpoints::GETALLSONGS) + .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 songs = &resp.data; + assert_eq!( + 2, + songs.len(), + "Returned song count does not match. Returned song count {:?} song count {}", + songs.len(), + 2 + ); + } + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } + + let _ = super::db_mgr::drop_database(&tm_pool, &db_name).await; + } } } diff --git a/test_migrations/20250725214735_song_queue_processed.sql b/test_migrations/20250725214735_song_queue_processed.sql index 759da00..05da4bb 100644 --- a/test_migrations/20250725214735_song_queue_processed.sql +++ b/test_migrations/20250725214735_song_queue_processed.sql @@ -1,3 +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.jpg', '44cf7940-34ff-489f-9124-d0ec90a55af9'); +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'); diff --git a/tests/I/Coverart.jpg b/tests/I/Coverart-1.jpg similarity index 100% rename from tests/I/Coverart.jpg rename to tests/I/Coverart-1.jpg diff --git a/tests/I/Coverart-2.jpg b/tests/I/Coverart-2.jpg new file mode 100644 index 0000000..3c631f8 Binary files /dev/null and b/tests/I/Coverart-2.jpg differ