diff --git a/.cargo/config.toml b/.cargo/config.toml index d4c65ec6c..fc2582823 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -36,5 +36,7 @@ PKG_CONFIG_PATH_x86_64-unknown-freebsd = { value = "target/vcpkg/installed/x64-f PKG_CONFIG_PATH_x86_64-apple-darwin = { value = "target/vcpkg/installed/x64-osx-release/lib/pkgconfig", relative = true } PKG_CONFIG_PATH_aarch64-apple-darwin = { value = "target/vcpkg/installed/arm64-osx-release/lib/pkgconfig", relative = true } +OPENSSL_STATIC = { value = "true" } + # Only Windows uses vcpkg linking. VCPKGRS_TRIPLET = { value = "x64-windows-static-release" } diff --git a/.gitignore b/.gitignore index bb19973f5..92f434ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,7 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +# Hearing test output +hearing-test-output/ + # End of https://www.toptal.com/developers/gitignore/api/c++,rust,linux,macos,windows,rust-analyzer,visualstudiocode,direnv diff --git a/Cargo.lock b/Cargo.lock index 58ea35f0c..6da86b6b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,12 +1,12 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -92,6 +92,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -131,6 +153,17 @@ dependencies = [ "syn 2.0.85", ] +[[package]] +name = "async-walkdir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20235b6899dd1cb74a9afac0abf5b4a20c0e500dd6537280f4096e1b9f14da20" +dependencies = [ + "async-fs", + "futures-lite", + "thiserror", +] + [[package]] name = "atomic" version = "0.6.0" @@ -493,8 +526,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", - "axum-core", - "axum-macros", + "axum-core 0.4.5", + "axum-macros 0.4.2", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower 0.5.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4132c8995c63b222c56f38b80748c23323d7012d1f783b0f42e92782c2676690" +dependencies = [ + "axum-core 0.5.0-alpha.1", + "axum-macros 0.5.0-alpha.1", "bytes", "futures-util", "http 1.1.0", @@ -503,7 +570,7 @@ dependencies = [ "hyper 1.3.1", "hyper-util", "itoa", - "matchit", + "matchit 0.8.0", "memchr", "mime", "percent-encoding", @@ -542,14 +609,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3405ae79f3aab93ae35fe2944aff9bf0f5aac918c0229f3528043c5a454d17d" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-extra" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" dependencies = [ - "axum", - "axum-core", + "axum 0.7.7", + "axum-core 0.4.5", "bytes", "futures-util", "headers", @@ -566,6 +653,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda8d71bb4e0d59ca7203822c50c4f0961aa5ed42c758d62cb413f0ba0aa9877" +dependencies = [ + "axum 0.8.0-alpha.1", + "axum-core 0.5.0-alpha.1", + "bytes", + "fastrand", + "futures-util", + "headers", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower 0.5.1", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-macros" version = "0.4.2" @@ -577,11 +688,22 @@ dependencies = [ "syn 2.0.85", ] +[[package]] +name = "axum-macros" +version = "0.5.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203d3637bf999b7611f52addb61581111a3ea32e3818df0cbbde3e95d0f22bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -658,6 +780,29 @@ dependencies = [ "which", ] +[[package]] +name = "bitcode" +version = "0.6.3" +source = "git+https://github.com/vnghia/bitcode.git?rev=5f6a56f#5f6a56fb717ab470bc0ae017cbfe1d1870610f56" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", + "uuid", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.3" +source = "git+https://github.com/vnghia/bitcode.git?rev=5f6a56f#5f6a56fb717ab470bc0ae017cbfe1d1870610f56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -695,6 +840,31 @@ dependencies = [ "piper", ] +[[package]] +name = "bon" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b4408cb90c75f462c2428254f2a687c399d1feb22ebdc7511889d07be6cab0" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc5494814aa273050386f95a7d1fa36dcc677fc796bffe54f34659678335bc0" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.82", +] + [[package]] name = "bstr" version = "1.9.1" @@ -900,6 +1070,33 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "compact_str" version = "0.7.1" @@ -953,6 +1150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -1111,9 +1309,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ "darling_core", "darling_macro", @@ -1121,9 +1319,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", @@ -1135,9 +1333,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", @@ -1215,7 +1413,7 @@ dependencies = [ "deluxe-core", "heck 0.4.1", "if_chain", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.85", @@ -1252,6 +1450,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-new" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "derive-new" version = "0.7.0" @@ -1287,7 +1496,7 @@ checksum = "158fe8e2e68695bd615d7e4f3227c0727b151330d3e253b525086c348d055d5e" dependencies = [ "bitflags 2.5.0", "byteorder", - "diesel_derives", + "diesel_derives 2.2.2", "itoa", "pq-sys", "time", @@ -1309,14 +1518,26 @@ dependencies = [ "tokio-postgres", ] +[[package]] +name = "diesel_derives" +version = "2.2.0" +source = "git+https://github.com/diesel-rs/diesel?rev=ecf6e9215a898045ed096f905f4a0e7e4a999dba#ecf6e9215a898045ed096f905f4a0e7e4a999dba" +dependencies = [ + "diesel_table_macro_syntax 0.2.0 (git+https://github.com/diesel-rs/diesel?rev=ecf6e9215a898045ed096f905f4a0e7e4a999dba)", + "dsl_auto_type 0.1.0", + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "diesel_derives" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6ff2be1e7312c858b2ef974f5c7089833ae57b5311b334b30923af58e5718d8" dependencies = [ - "diesel_table_macro_syntax", - "dsl_auto_type", + "diesel_table_macro_syntax 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dsl_auto_type 0.1.2", "proc-macro2", "quote", "syn 2.0.85", @@ -1351,6 +1572,14 @@ dependencies = [ "syn 2.0.85", ] +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "git+https://github.com/diesel-rs/diesel?rev=ecf6e9215a898045ed096f905f4a0e7e4a999dba#ecf6e9215a898045ed096f905f4a0e7e4a999dba" +dependencies = [ + "syn 2.0.82", +] + [[package]] name = "digest" version = "0.10.7" @@ -1716,6 +1945,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "dsl_auto_type" +version = "0.1.0" +source = "git+https://github.com/diesel-rs/diesel?rev=ecf6e9215a898045ed096f905f4a0e7e4a999dba#ecf6e9215a898045ed096f905f4a0e7e4a999dba" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "dsl_auto_type" version = "0.1.2" @@ -1732,9 +1974,9 @@ dependencies = [ [[package]] name = "dummy" -version = "0.7.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e57e12b69e57fad516e01e2b3960f122696fdb13420e1a88ed8e210316f2876" +checksum = "b3ee4e39146145f7dd28e6c85ffdce489d93c0d9c88121063b8aacabbd9858d2" dependencies = [ "darling", "proc-macro2", @@ -1907,16 +2149,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fake" -version = "2.9.2" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25829bde82205da46e1823b2259db6273379f626fc211f126f65654a2669be" +checksum = "661cb0601b5f4050d1e65452c5b0ea555c0b3e88fb5ed7855906adc6c42523ef" dependencies = [ "deunicode", "dummy", "rand", - "serde_json", "time", "uuid", ] @@ -1927,6 +2178,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless 0.8.0", + "serde", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -2023,6 +2284,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e871a4cfa68bb224863b53149d973df1ac8d1ed2fa1d1bfc37ac1bb65dd37207" +dependencies = [ + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "futures" version = "0.3.31" @@ -2089,7 +2360,10 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -2116,6 +2390,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -2181,9 +2461,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "git2" @@ -2198,6 +2478,12 @@ dependencies = [ "url", ] +[[package]] +name = "glam" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28091a37a5d09b555cb6628fd954da299b536433834f5b8e59eba78e0cbbf8a" + [[package]] name = "glob" version = "0.3.1" @@ -2558,7 +2844,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.85", @@ -2587,7 +2873,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.2.6", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2606,7 +2892,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap 2.2.6", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2632,6 +2918,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2648,6 +2943,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "headers" version = "0.4.0" @@ -2679,13 +2980,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2977,9 +3288,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.4" +version = "0.25.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" dependencies = [ "bytemuck", "byteorder-lite", @@ -2989,6 +3300,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "1.9.3" @@ -3002,12 +3319,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.0", "serde", ] @@ -3095,9 +3412,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jobserver" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] @@ -3122,6 +3439,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "konst" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330f0e13e6483b8c34885f7e6c9f19b1a7bd449c673fbb948a51c99d66ef74f4" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "krates" version = "0.16.10" @@ -3208,7 +3540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3233,9 +3565,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.18" +version = "1.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e" +checksum = "fdc53a7799a7496ebc9fd29f31f7df80e83c9bda5299768af5f9e59eeea74647" dependencies = [ "cc", "libc", @@ -3274,6 +3606,21 @@ dependencies = [ "paste", ] +[[package]] +name = "lofty" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bc4717ff10833a623b009e9254ae8667c7a59edc3cfb01c37aeeef4b6d54a7" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + [[package]] name = "lofty_attr" version = "0.11.0" @@ -3287,9 +3634,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "longest-increasing-subsequence" @@ -3297,6 +3644,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" +[[package]] +name = "loole" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2998397c725c822c6b2ba605fd9eb4c6a7a0810f1629ba3cc232ef4f0308d96" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "loom" version = "0.5.6" @@ -3385,6 +3742,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e8fcd7bd6025a951597d6ba2f8e48a121af7e262f2b52a006a09c8d61f9304" + [[package]] name = "maybe-async" version = "0.2.10" @@ -3492,6 +3855,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -3526,8 +3906,8 @@ dependencies = [ "async-trait", "aws-config", "aws-sdk-s3", - "axum", - "axum-extra", + "axum 0.7.7", + "axum-extra 0.9.4", "concat-string", "constcat 0.5.1", "derivative", @@ -3548,7 +3928,7 @@ dependencies = [ "itertools", "lastfm-client", "libaes", - "lofty", + "lofty 0.20.1", "lrc", "mimalloc", "mime_guess", @@ -3575,7 +3955,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "typed-path", + "typed-path 0.9.3", "unicode-normalization", "url", "uuid", @@ -3632,7 +4012,7 @@ dependencies = [ "concat-string", "constcat 0.5.1", "derivative", - "derive-new", + "derive-new 0.7.0", "fake", "isolang", "md5", @@ -3646,6 +4026,104 @@ dependencies = [ "uuid", ] +[[package]] +name = "nghe_api" +version = "0.9.10" +dependencies = [ + "bitcode", + "bon", + "built", + "const_format", + "fake", + "faster-hex", + "md5", + "nghe_proc_macro", + "paste", + "rstest", + "serde", + "serde_html_form", + "serde_json", + "serde_with", + "strum", + "uuid", +] + +[[package]] +name = "nghe_backend" +version = "0.9.10" +dependencies = [ + "async-walkdir", + "aws-config", + "aws-sdk-s3", + "aws-smithy-runtime", + "axum 0.8.0-alpha.1", + "axum-extra 0.10.0-alpha.1", + "bitcode", + "bon", + "color-eyre", + "concat-string", + "const_format", + "derive-new 0.6.0", + "diesel", + "diesel-async", + "diesel_derives 2.2.0", + "diesel_full_text_search", + "diesel_migrations", + "educe", + "fake", + "faster-hex", + "figment", + "fs4", + "futures-lite", + "http-body-util", + "hyper 0.14.29", + "hyper-tls", + "image", + "indexmap 2.6.0", + "isolang", + "itertools", + "libaes", + "lofty 0.21.1", + "loole", + "nghe_api", + "nghe_proc_macro", + "num-traits", + "o2o", + "rand", + "rsmpeg", + "rstest", + "serde", + "serde_html_form", + "serde_with", + "strum", + "tempfile", + "thiserror", + "time", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "typed-path 0.10.0", + "unicode-normalization", + "url", + "uuid", + "xxhash-rust", +] + +[[package]] +name = "nghe_proc_macro" +version = "0.9.10" +dependencies = [ + "bon", + "concat-string", + "convert_case", + "deluxe", + "proc-macro2", + "quote", + "syn 2.0.82", +] + [[package]] name = "nom" version = "7.1.3" @@ -3700,11 +4178,42 @@ dependencies = [ "libc", ] +[[package]] +name = "o2o" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e305dfc777160d7b28491f1302858d0b290752e0d7e36b26d5993f89e29831" +dependencies = [ + "o2o-impl", + "o2o-macros", +] + +[[package]] +name = "o2o-impl" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "946513572d14a570bfac1400f1ea584e9d788acee7862af973652125212614c4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "o2o-macros" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18ce235841f140d13a3f76c4bb7cff9a5a913e4f10ddd6185e6584d4b4e2e502" +dependencies = [ + "o2o-impl", + "syn 1.0.109", +] + [[package]] name = "object" -version = "0.36.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -3789,6 +4298,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "p256" version = "0.11.1" @@ -3871,7 +4386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.2.6", + "indexmap 2.6.0", ] [[package]] @@ -4007,7 +4522,7 @@ checksum = "a55c51ee6c0db07e68448e336cf8ea4131a620edefebf9893e759b2d793420f8" dependencies = [ "cobs", "embedded-io", - "heapless", + "heapless 0.7.17", "serde", ] @@ -4081,6 +4596,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit 0.22.20", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -4299,6 +4823,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.11.27" @@ -4478,6 +5008,36 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rstest" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.82", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4792,7 +5352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5" dependencies = [ "form_urlencoded", - "indexmap 2.2.6", + "indexmap 2.6.0", "itoa", "ryu", "serde", @@ -4844,9 +5404,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" dependencies = [ "serde", ] @@ -4873,7 +5433,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.2.6", + "indexmap 2.6.0", "serde", "serde_derive", "serde_json", @@ -5461,14 +6021,14 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.16", + "toml_edit 0.22.20", ] [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] @@ -5479,7 +6039,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.6.0", "serde", "serde_spanned", "toml_datetime", @@ -5488,15 +6048,15 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.16" +version = "0.22.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" +checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.6.0", "serde", "serde_spanned", "toml_datetime", - "winnow 0.6.13", + "winnow 0.6.18", ] [[package]] @@ -5600,6 +6160,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -5652,6 +6222,11 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82205ffd44a9697e34fc145491aa47310f9871540bb7909eaa9365e0a9a46607" +[[package]] +name = "typed-path" +version = "0.10.0" +source = "git+https://github.com/vnghia/typed-path?rev=bd796e64b3cee53181a3fc2f15245f5bf731bd8c#bd796e64b3cee53181a3fc2f15245f5bf731bd8c" + [[package]] name = "typenum" version = "1.17.0" @@ -6175,9 +6750,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.13" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 259959ea8..c08134389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,12 +5,28 @@ members = [ "lastfm-proc-macros", "proc_macros", "types", + "nghe-api", + "nghe-proc-macro", + "nghe-backend", ] [workspace.package] version = "0.9.10" edition = "2021" +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(rust_analyzer)"] } + +[workspace.lints.clippy] +pedantic = { level = "deny", priority = -1 } +cast_possible_wrap = { level = "allow", priority = 0 } +duplicated_attributes = { level = "allow", priority = 0 } +missing_panics_doc = { level = "allow", priority = 0 } +must_use_candidate = { level = "allow", priority = 0 } +return_self_not_must_use = { level = "allow", priority = 0 } +struct_excessive_bools = { level = "allow", priority = 0 } +wildcard_imports = { level = "allow", priority = 0 } + [package] name = "nghe" version = { workspace = true } @@ -18,9 +34,19 @@ edition = { workspace = true } [workspace.dependencies] anyhow = { version = "1.0.86", features = ["backtrace"] } +bitcode = { git = "https://github.com/vnghia/bitcode.git", rev = "5f6a56f", features = [ + "uuid", +] } +bon = { version = "3.0.1" } +color-eyre = { version = "0.6.3" } concat-string = { version = "1.0.1" } +const_format = { version = "0.2.32", features = ["fmt"] } constcat = { version = "0.5.0" } +convert_case = { version = "0.6.0" } derivative = { version = "2.2.0" } +derive-new = { version = "0.6.0" } +educe = { version = "0.6.0" } +faster-hex = { version = "0.10.0" } hex = { version = "0.4.3" } isolang = { version = "2.4.0", default-features = false, features = ["serde"] } itertools = { version = "0.12.1" } @@ -37,18 +63,13 @@ reqwest = { version = "0.12.5", default-features = false, features = [ "json", "http2", ] } +rstest = { version = "0.22.0" } thiserror = { version = "1.0.61" } -tokio = { version = "1.38.0", features = ["full"] } url = { version = "2.5.2" } # Dev serde_json = { version = "1.0.120" } -fake = { version = "2.9.2", features = [ - "derive", - "uuid", - "serde_json", - "time", -] } +fake = { version = "3.0.1", features = ["derive", "uuid", "time"] } [dependencies] anyhow = { workspace = true } @@ -66,7 +87,6 @@ serde_with = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } time = { workspace = true } -tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } @@ -113,6 +133,7 @@ rspotify = { version = "0.13.2", default-features = false, features = [ "client-reqwest", "reqwest-rustls-tls", ] } +tokio = { version = "1.40.0", features = ["full"] } tokio-postgres = { version = "0.7.10" } tokio-util = { version = "0.7.11", features = ["io"] } tower = { version = "0.5.0", features = ["util"] } @@ -138,6 +159,9 @@ uuid = { version = "1.9.1", features = ["v4", "fast-rng"] } [target.'cfg(not(any(target_env = "musl", all(target_arch = "aarch64", target_os = "linux"))))'.dev-dependencies] diesel = { version = "2.2.2", features = ["postgres"] } +[profile.dev.package.backtrace] +opt-level = 3 + [profile.release] opt-level = 3 strip = "debuginfo" diff --git a/diesel.toml b/diesel.toml index eabd2d6c0..d1dd4724a 100644 --- a/diesel.toml +++ b/diesel.toml @@ -2,10 +2,10 @@ # see https://diesel.rs/guides/configuring-diesel-cli [print_schema] -file = "src/schema.rs" +file = "nghe-backend/src/schema.rs" custom_type_derives = ["diesel::query_builder::QueryId"] import_types = ["diesel::sql_types::*", "diesel_full_text_search::*"] generate_missing_sql_type_definitions = false [migrations_directory] -dir = "migrations" +dir = "nghe-backend/migrations" diff --git a/lastfm-client/Cargo.toml b/lastfm-client/Cargo.toml index 858739ddc..4b6b9693d 100644 --- a/lastfm-client/Cargo.toml +++ b/lastfm-client/Cargo.toml @@ -20,4 +20,4 @@ serde_repr = { version = "0.1.19" } [dev-dependencies] fake = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = ["macros"] } +tokio = { version = "1", features = ["macros"] } diff --git a/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql b/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql new file mode 100644 index 000000000..de6d203a1 --- /dev/null +++ b/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table music_folders drop column created_at; diff --git a/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql b/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql new file mode 100644 index 000000000..b2adff483 --- /dev/null +++ b/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +alter table music_folders +add column created_at timestamptz not null default now(); diff --git a/nghe-api/Cargo.toml b/nghe-api/Cargo.toml new file mode 100644 index 000000000..dd6546933 --- /dev/null +++ b/nghe-api/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "nghe_api" +version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dependencies] +bitcode = { workspace = true } +bon = { workspace = true } +const_format = { workspace = true } +faster-hex = { workspace = true } +serde = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +uuid = { workspace = true } + +fake = { workspace = true, optional = true } + +nghe_proc_macro = { path = "../nghe-proc-macro" } + +md5 = { version = "0.7.0" } +paste = { version = "1.0.15" } + +[dev-dependencies] +fake = { workspace = true } +rstest = { workspace = true } +serde_json = { workspace = true } +serde_html_form = { workspace = true } + +[build-dependencies] +built = { version = "0.7.3", features = ["git2"] } + +[features] +test = ["fake"] diff --git a/nghe-api/build.rs b/nghe-api/build.rs new file mode 100644 index 000000000..4cae17a2f --- /dev/null +++ b/nghe-api/build.rs @@ -0,0 +1,3 @@ +fn main() { + built::write_built_file().expect("Could not acquire build-time information"); +} diff --git a/nghe-api/src/auth/mod.rs b/nghe-api/src/auth/mod.rs new file mode 100644 index 000000000..bfce92b67 --- /dev/null +++ b/nghe-api/src/auth/mod.rs @@ -0,0 +1,75 @@ +mod token; + +use nghe_proc_macro::api_derive; +pub use token::Token; + +#[api_derive(request = true, response = true, fake = false)] +#[derive(Clone, Copy)] +pub struct Auth<'u, 't> { + #[serde(rename = "u")] + pub username: &'u str, + #[serde(rename = "s")] + pub salt: &'t str, + #[serde(rename = "t")] + pub token: Token, +} + +#[api_derive(json = false, fake = false)] +pub struct AuthRequest<'u, 't, R> { + pub auth: Auth<'u, 't>, + pub request: R, +} + +impl Auth<'_, '_> { + pub fn tokenize(password: impl AsRef<[u8]>, salt: impl AsRef<[u8]>) -> Token { + let password = password.as_ref(); + let salt = salt.as_ref(); + + let mut data = Vec::with_capacity(password.len() + salt.len()); + data.extend_from_slice(password); + data.extend_from_slice(salt); + Token(md5::compute(data).into()) + } + + pub fn check(password: impl AsRef<[u8]>, salt: impl AsRef<[u8]>, token: &Token) -> bool { + let password = password.as_ref(); + let salt = salt.as_ref(); + + let password_token = Self::tokenize(password, salt); + &password_token == token + } +} + +#[cfg(test)] +mod tests { + use fake::faker::internet::en::Password; + use fake::Fake; + use serde_json::{from_value, json}; + + use super::*; + + #[test] + fn test_tokenize() { + assert_eq!( + from_value::(json!("26719a1196d2a940705a59634eb18eab")).unwrap(), + Auth::tokenize(b"sesame", b"c19b2d") + ); + } + + #[test] + fn test_check_success() { + let password = Password(16..32).fake::().into_bytes(); + let client_salt = Password(8..16).fake::().into_bytes(); + let client_token = Auth::tokenize(&password, &client_salt); + assert!(Auth::check(password, client_salt, &client_token)); + } + + #[test] + fn test_check_failed() { + let password = Password(16..32).fake::().into_bytes(); + let client_salt = Password(8..16).fake::().into_bytes(); + let wrong_client_salt = Password(8..16).fake::().into_bytes(); + let client_token = Auth::tokenize(&password, client_salt); + assert!(!Auth::check(password, wrong_client_salt, &client_token)); + } +} diff --git a/nghe-api/src/auth/token.rs b/nghe-api/src/auth/token.rs new file mode 100644 index 000000000..27a1acde5 --- /dev/null +++ b/nghe-api/src/auth/token.rs @@ -0,0 +1,32 @@ +use nghe_proc_macro::api_derive; + +#[api_derive(request = true, response = true, json = false, eq = false)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Token(pub [u8; 16]); + +mod serde { + use ::serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + + use super::*; + + impl Serialize for Token { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + faster_hex::nopfx_ignorecase::serialize(self.0, serializer) + } + } + + impl<'de> Deserialize<'de> for Token { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let data: Vec = faster_hex::nopfx_ignorecase::deserialize(deserializer)?; + Ok(Token(data.try_into().map_err(|_| { + de::Error::custom("Could not convert vector to array of length 16") + })?)) + } + } +} diff --git a/nghe-api/src/browsing/get_album.rs b/nghe-api/src/browsing/get_album.rs new file mode 100644 index 000000000..a7d9fff8d --- /dev/null +++ b/nghe-api/src/browsing/get_album.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getAlbum")] +pub struct Request { + pub id: Uuid, +} + +#[api_derive] +pub struct Response { + pub album: id3::album::Full, +} diff --git a/nghe-api/src/browsing/get_album_info2.rs b/nghe-api/src/browsing/get_album_info2.rs new file mode 100644 index 000000000..323b05c77 --- /dev/null +++ b/nghe-api/src/browsing/get_album_info2.rs @@ -0,0 +1,22 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "getAlbumInfo2")] +pub struct Request { + pub id: Uuid, +} + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], +)] +#[api_derive(response = true)] +pub struct AlbumInfo { + // TODO: add notes field + pub music_brainz_id: Option, +} + +#[api_derive] +pub struct Response { + pub album_info: AlbumInfo, +} diff --git a/nghe-api/src/browsing/get_artist.rs b/nghe-api/src/browsing/get_artist.rs new file mode 100644 index 000000000..55d4c14b2 --- /dev/null +++ b/nghe-api/src/browsing/get_artist.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getArtist")] +pub struct Request { + pub id: Uuid, +} + +#[api_derive] +pub struct Response { + pub artist: id3::artist::Full, +} diff --git a/nghe-api/src/browsing/get_artist_info2.rs b/nghe-api/src/browsing/get_artist_info2.rs new file mode 100644 index 000000000..5ab60a4b7 --- /dev/null +++ b/nghe-api/src/browsing/get_artist_info2.rs @@ -0,0 +1,22 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "getArtistInfo2")] +pub struct Request { + pub id: Uuid, +} + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], +)] +#[api_derive(response = true)] +pub struct ArtistInfo2 { + // TODO: add biography and lastfm url field + pub music_brainz_id: Option, +} + +#[api_derive] +pub struct Response { + pub artist_info2: ArtistInfo2, +} diff --git a/nghe-api/src/browsing/get_artists.rs b/nghe-api/src/browsing/get_artists.rs new file mode 100644 index 000000000..6540be1a0 --- /dev/null +++ b/nghe-api/src/browsing/get_artists.rs @@ -0,0 +1,28 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getArtists")] +pub struct Request { + #[serde(rename = "musicFolderId")] + pub music_folder_ids: Option>, +} + +#[api_derive(response = true)] +pub struct Index { + pub name: String, + pub artist: Vec, +} + +#[api_derive(response = true)] +pub struct Artists { + pub ignored_articles: String, + pub index: Vec, +} + +#[api_derive] +pub struct Response { + pub artists: Artists, +} diff --git a/nghe-api/src/browsing/get_genres.rs b/nghe-api/src/browsing/get_genres.rs new file mode 100644 index 000000000..eb464662c --- /dev/null +++ b/nghe-api/src/browsing/get_genres.rs @@ -0,0 +1,17 @@ +use nghe_proc_macro::api_derive; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getGenres")] +pub struct Request {} + +#[api_derive(response = true)] +pub struct Genres { + pub genre: Vec, +} + +#[api_derive] +pub struct Response { + pub genres: Genres, +} diff --git a/nghe-api/src/browsing/get_music_folders.rs b/nghe-api/src/browsing/get_music_folders.rs new file mode 100644 index 000000000..0bb6560ff --- /dev/null +++ b/nghe-api/src/browsing/get_music_folders.rs @@ -0,0 +1,22 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "getMusicFolders")] +pub struct Request {} + +#[api_derive(response = true)] +pub struct MusicFolder { + pub id: Uuid, + pub name: String, +} + +#[api_derive(response = true)] +pub struct MusicFolders { + pub music_folder: Vec, +} + +#[api_derive] +pub struct Response { + pub music_folders: MusicFolders, +} diff --git a/nghe-api/src/browsing/get_song.rs b/nghe-api/src/browsing/get_song.rs new file mode 100644 index 000000000..aff305c01 --- /dev/null +++ b/nghe-api/src/browsing/get_song.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getSong")] +pub struct Request { + pub id: Uuid, +} + +#[api_derive] +pub struct Response { + pub song: id3::song::Full, +} diff --git a/nghe-api/src/browsing/get_top_songs.rs b/nghe-api/src/browsing/get_top_songs.rs new file mode 100644 index 000000000..0ac50e882 --- /dev/null +++ b/nghe-api/src/browsing/get_top_songs.rs @@ -0,0 +1,20 @@ +use nghe_proc_macro::api_derive; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getTopSongs")] +pub struct Request { + pub artist: String, + pub count: Option, +} + +#[api_derive(response = true)] +pub struct TopSongs { + pub song: Vec, +} + +#[api_derive] +pub struct Response { + pub top_songs: TopSongs, +} diff --git a/nghe-api/src/browsing/mod.rs b/nghe-api/src/browsing/mod.rs new file mode 100644 index 000000000..76eb94ed7 --- /dev/null +++ b/nghe-api/src/browsing/mod.rs @@ -0,0 +1,9 @@ +pub mod get_album; +pub mod get_album_info2; +pub mod get_artist; +pub mod get_artist_info2; +pub mod get_artists; +pub mod get_genres; +pub mod get_music_folders; +pub mod get_song; +pub mod get_top_songs; diff --git a/nghe-api/src/common/filesystem.rs b/nghe-api/src/common/filesystem.rs new file mode 100644 index 000000000..d4fd2d041 --- /dev/null +++ b/nghe-api/src/common/filesystem.rs @@ -0,0 +1,11 @@ +use std::marker::ConstParamTy; + +use nghe_proc_macro::api_derive; + +#[repr(i16)] +#[api_derive(request = true)] +#[derive(ConstParamTy)] +pub enum Type { + Local, + S3, +} diff --git a/nghe-api/src/common/format.rs b/nghe-api/src/common/format.rs new file mode 100644 index 000000000..d0a8bbbb1 --- /dev/null +++ b/nghe-api/src/common/format.rs @@ -0,0 +1,37 @@ +use nghe_proc_macro::api_derive; +use strum::{EnumString, IntoStaticStr}; + +pub trait Trait: Copy { + fn mime(&self) -> &'static str; + fn extension(&self) -> &'static str; +} + +#[api_derive(request = true)] +#[derive(IntoStaticStr, EnumString)] +#[strum(serialize_all = "lowercase")] +#[cfg_attr(feature = "test", derive(strum::AsRefStr))] +pub enum Transcode { + Aac, + Flac, + Mp3, + Opus, + Wav, + Wma, +} + +impl Trait for Transcode { + fn mime(&self) -> &'static str { + match self { + Self::Aac => "audio/aac", + Self::Flac => "audio/flac", + Self::Mp3 => "audio/mpeg", + Self::Opus => "audio/ogg", + Self::Wav => "audio/wav", + Self::Wma => "audio/x-ms-wma", + } + } + + fn extension(&self) -> &'static str { + self.into() + } +} diff --git a/nghe-api/src/common/mod.rs b/nghe-api/src/common/mod.rs new file mode 100644 index 000000000..1557c2682 --- /dev/null +++ b/nghe-api/src/common/mod.rs @@ -0,0 +1,160 @@ +pub mod filesystem; +pub mod format; +pub mod typed_uuid; + +use bitcode::{DecodeOwned, Encode}; +use nghe_proc_macro::api_derive; +use serde::de::DeserializeOwned; +use serde::{Serialize, Serializer}; + +use super::constant; + +#[api_derive(debug = false, binary = false)] +struct RootResponse { + #[serde(serialize_with = "emit_open_subsonic_version")] + version: (), + #[serde(serialize_with = "emit_server_type")] + r#type: (), + #[serde(serialize_with = "emit_server_version")] + server_version: (), + #[serde(serialize_with = "emit_open_subsonic")] + open_subsonic: (), + #[serde(serialize_with = "emit_status_ok")] + status: (), + #[serde(flatten)] + body: B, +} + +#[api_derive(debug = false, binary = false)] +pub struct SubsonicResponse { + #[serde(rename = "subsonic-response")] + root: RootResponse, +} + +pub trait JsonURL { + const URL: &'static str; + const URL_VIEW: &'static str; +} + +pub trait JsonRequest = JsonURL + DeserializeOwned; + +pub trait JsonEndpoint: JsonRequest { + type Response: Serialize; +} + +pub trait BinaryURL { + const URL_BINARY: &'static str; +} + +pub trait BinaryRequest = BinaryURL + Encode + DecodeOwned; + +pub trait BinaryEndpoint: BinaryRequest { + type Response: Encode + DecodeOwned; +} + +impl SubsonicResponse { + pub fn new(body: B) -> Self { + Self { + root: RootResponse { + version: (), + r#type: (), + server_version: (), + open_subsonic: (), + status: (), + body, + }, + } + } + + pub fn body(self) -> B { + self.root.body + } +} + +macro_rules! emit_constant_serialize { + ($constant_name:ident, $constant_type:ty, $constant_value:expr) => { + paste::paste! { + fn [](_: &(), s: S) -> Result { + s.[]($constant_value) + } + } + }; +} + +emit_constant_serialize!(open_subsonic_version, str, constant::OPEN_SUBSONIC_VERSION); +emit_constant_serialize!(server_type, str, constant::SERVER_NAME); +emit_constant_serialize!(server_version, str, constant::SERVER_VERSION); +emit_constant_serialize!(open_subsonic, bool, true); +emit_constant_serialize!(status_ok, str, "ok"); + +#[cfg(test)] +mod tests { + use serde_json::{json, to_value}; + + use super::*; + + #[test] + fn test_serialize_empty() { + #[api_derive(request = true, response = true, debug = false, binary = false)] + struct TestBody {} + + assert_eq!( + to_value(SubsonicResponse::new(TestBody {})).unwrap(), + json!({ + "subsonic-response": { + "status": "ok", + "version": constant::OPEN_SUBSONIC_VERSION, + "type": constant::SERVER_NAME, + "serverVersion": constant::SERVER_VERSION, + "openSubsonic": true + } + }) + ); + } + + #[test] + fn test_serialize() { + #[api_derive(request = true, response = true, debug = false, binary = false)] + struct TestBody { + field: u16, + } + let field = 10; + + assert_eq!( + to_value(SubsonicResponse::new(TestBody { field })).unwrap(), + json!({ + "subsonic-response": { + "field": field, + "status": "ok", + "version": constant::OPEN_SUBSONIC_VERSION, + "type": constant::SERVER_NAME, + "serverVersion": constant::SERVER_VERSION, + "openSubsonic": true + } + }) + ); + } + + #[test] + fn test_serialize_case() { + #[api_derive(request = true, response = true, debug = false, binary = false)] + struct TestBody { + snake_case: u16, + } + let snake_case = 10; + + assert_eq!( + to_value(SubsonicResponse::new(TestBody { snake_case })).unwrap(), + json!({ + "subsonic-response": { + "snakeCase": snake_case, + "status": "ok", + "version": constant::OPEN_SUBSONIC_VERSION, + "type": constant::SERVER_NAME, + "serverVersion": constant::SERVER_VERSION, + "openSubsonic": true + } + }) + ); + } +} diff --git a/nghe-api/src/common/typed_uuid.rs b/nghe-api/src/common/typed_uuid.rs new file mode 100644 index 000000000..ea8bc9448 --- /dev/null +++ b/nghe-api/src/common/typed_uuid.rs @@ -0,0 +1,131 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive(request = true, response = true, json = false)] +pub enum Type { + Artist, + Album, + Song, +} + +#[api_derive(request = true, response = true, json = false)] +pub struct TypedUuid { + pub ty: Type, + pub id: Uuid, +} + +mod serde { + use core::str; + use std::str::FromStr; + + use ::serde::{de, ser, Deserialize, Deserializer, Serialize, Serializer}; + use serde_with::DeserializeAs; + + use super::*; + + const SIMPLE_LENGTH: usize = uuid::fmt::Simple::LENGTH; + const BUFFER_LENGTH: usize = SIMPLE_LENGTH + 2; + const ARTIST_BYTE: &[u8; 2] = b"ar"; + const ALBUM_BYTE: &[u8; 2] = b"al"; + const SONG_BYTE: &[u8; 2] = b"so"; + + impl Serialize for TypedUuid { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut buffer = [0u8; BUFFER_LENGTH]; + buffer[SIMPLE_LENGTH..BUFFER_LENGTH].copy_from_slice(match self.ty { + Type::Artist => ARTIST_BYTE, + Type::Album => ALBUM_BYTE, + Type::Song => SONG_BYTE, + }); + self.id.simple().encode_lower(&mut buffer); + serializer.serialize_str(str::from_utf8(&buffer).map_err(ser::Error::custom)?) + } + } + + impl<'de> Deserialize<'de> for TypedUuid { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let buffer: [u8; BUFFER_LENGTH] = serde_with::Bytes::deserialize_as(deserializer)?; + + let ty_buffer: &[u8; 2] = + &buffer[SIMPLE_LENGTH..BUFFER_LENGTH].try_into().map_err(de::Error::custom)?; + let ty = match ty_buffer { + ARTIST_BYTE => Type::Artist, + ALBUM_BYTE => Type::Album, + SONG_BYTE => Type::Song, + _ => return Err(de::Error::custom("uuid type is invalid")), + }; + + let id = Uuid::from_str( + str::from_utf8(&buffer[..SIMPLE_LENGTH]).map_err(de::Error::custom)?, + ) + .map_err(de::Error::custom)?; + + Ok(Self { ty, id }) + } + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use rstest::rstest; + use uuid::uuid; + + use super::*; + + #[api_derive(request = true, response = true)] + struct Test { + pub id: TypedUuid, + } + + #[rstest] + #[case( + "id=a5b704dc8a7d4ff194896f8cc8890621ar", + Some(TypedUuid { ty: Type::Artist, id: uuid!("a5b704dc-8a7d-4ff1-9489-6f8cc8890621") }) + )] + #[case( + "id=d22aee56cb844605b6a18e3b6d3aff72al", + Some(TypedUuid { ty: Type::Album, id: uuid!("d22aee56-cb84-4605-b6a1-8e3b6d3aff72") }) + )] + #[case( + "id=e57894fcfcfb442fa09f9c5844232bd0so", + Some(TypedUuid { ty: Type::Song, id: uuid!("e57894fc-fcfb-442f-a09f-9c5844232bd0") }) + )] + #[case("id=e57894fcfcfb442fa09f9c5844232bd0in", None)] + #[case("id=invalidar", None)] + fn test_deserialize(#[case] input: &str, #[case] typed_uuid: Option) { + assert_eq!(serde_html_form::from_str(input).ok(), typed_uuid.map(|id| Test { id })); + } + + #[rstest] + #[case( + TypedUuid { ty: Type::Artist, id: uuid!("2fcc59a3-ae4e-496c-a981-74eb04827ef1") }, + "id=2fcc59a3ae4e496ca98174eb04827ef1ar", + )] + #[case( + TypedUuid { ty: Type::Album, id: uuid!("3742eefb-1a9c-4c37-8653-ef07d12bdc31") }, + "id=3742eefb1a9c4c378653ef07d12bdc31al", + )] + #[case( + TypedUuid { ty: Type::Song, id: uuid!("48c02916-0d4c-417d-ae61-1b1c36eb3e42") }, + "id=48c029160d4c417dae611b1c36eb3e42so", + )] + fn test_serialize(#[case] typed_uuid: TypedUuid, #[case] result: &str) { + assert_eq!(serde_html_form::to_string(Test { id: typed_uuid }).unwrap(), result); + } + + #[rstest] + fn test_roundtrip() { + let test = Test { id: Faker.fake() }; + assert_eq!( + test, + serde_html_form::from_str(&serde_html_form::to_string(&test).unwrap()).unwrap() + ); + } +} diff --git a/nghe-api/src/constant.rs b/nghe-api/src/constant.rs new file mode 100644 index 000000000..40d88677b --- /dev/null +++ b/nghe-api/src/constant.rs @@ -0,0 +1,12 @@ +mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} + +pub const OPEN_SUBSONIC_VERSION: &str = "1.16.1"; +pub const SERVER_NAME: &str = "nghe"; +pub const SERVER_VERSION: &str = const_format::concatc!( + built_info::PKG_VERSION, + " (", + built_info::GIT_COMMIT_HASH_SHORT.unwrap(), + ")" +); diff --git a/nghe-api/src/id3/album/full.rs b/nghe-api/src/id3/album/full.rs new file mode 100644 index 000000000..56ba78544 --- /dev/null +++ b/nghe-api/src/id3/album/full.rs @@ -0,0 +1,16 @@ +use nghe_proc_macro::api_derive; + +use super::Album; +use crate::id3::{artist, song}; + +#[serde_with::apply( + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], +)] +#[api_derive(response = true)] +pub struct Full { + #[serde(flatten)] + pub album: Album, + pub artists: Vec, + pub is_compilation: bool, + pub song: Vec, +} diff --git a/nghe-api/src/id3/album/mod.rs b/nghe-api/src/id3/album/mod.rs new file mode 100644 index 000000000..2a92fa825 --- /dev/null +++ b/nghe-api/src/id3/album/mod.rs @@ -0,0 +1,36 @@ +mod full; + +use bon::Builder; +pub use full::Full; +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::{artist, date, genre}; + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], + date::Date => #[serde(skip_serializing_if = "date::Date::is_none")], + genre::Genres => #[serde(skip_serializing_if = "genre::Genres::is_empty")], +)] +#[api_derive(response = true)] +#[derive(Builder)] +#[builder(on(_, required))] +#[builder(state_mod(vis = "pub"))] +pub struct Album { + pub id: Uuid, + pub name: String, + pub cover_art: Option, + pub song_count: u16, + pub duration: u32, + pub year: Option, + pub music_brainz_id: Option, + #[builder(default)] + pub genres: genre::Genres, + #[builder(default)] + pub artists: Vec, + #[builder(default)] + pub original_release_date: date::Date, + #[builder(default)] + pub release_date: date::Date, +} diff --git a/nghe-api/src/id3/artist/full.rs b/nghe-api/src/id3/artist/full.rs new file mode 100644 index 000000000..4a5938de5 --- /dev/null +++ b/nghe-api/src/id3/artist/full.rs @@ -0,0 +1,14 @@ +use nghe_proc_macro::api_derive; + +use super::Artist; +use crate::id3::album; + +#[serde_with::apply( + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], +)] +#[api_derive(response = true)] +pub struct Full { + #[serde(flatten)] + pub artist: Artist, + pub album: Vec, +} diff --git a/nghe-api/src/id3/artist/mod.rs b/nghe-api/src/id3/artist/mod.rs new file mode 100644 index 000000000..6c160ea88 --- /dev/null +++ b/nghe-api/src/id3/artist/mod.rs @@ -0,0 +1,49 @@ +mod full; +mod required; + +use bon::Builder; +pub use full::Full; +use nghe_proc_macro::api_derive; +pub use required::Required; +use strum::IntoStaticStr; +use uuid::Uuid; + +#[api_derive(response = true, json = false)] +#[derive(IntoStaticStr)] +#[strum(serialize_all = "lowercase")] +pub enum Role { + Artist, + AlbumArtist, +} + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], +)] +#[api_derive(response = true)] +#[derive(Builder)] +#[builder(on(_, required))] +#[builder(state_mod(vis = "pub"))] +pub struct Artist { + #[serde(flatten)] + pub required: Required, + pub album_count: u16, + pub music_brainz_id: Option, + #[builder(default)] + pub roles: Vec, +} + +mod serde { + use ::serde::{Serialize, Serializer}; + + use super::*; + + impl Serialize for Role { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.into()) + } + } +} diff --git a/nghe-api/src/id3/artist/required.rs b/nghe-api/src/id3/artist/required.rs new file mode 100644 index 000000000..5e14a8c16 --- /dev/null +++ b/nghe-api/src/id3/artist/required.rs @@ -0,0 +1,8 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive(response = true)] +pub struct Required { + pub id: Uuid, + pub name: String, +} diff --git a/nghe-api/src/id3/date.rs b/nghe-api/src/id3/date.rs new file mode 100644 index 000000000..e825f3e7b --- /dev/null +++ b/nghe-api/src/id3/date.rs @@ -0,0 +1,18 @@ +use nghe_proc_macro::api_derive; + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], +)] +#[api_derive(response = true)] +#[derive(Default)] +pub struct Date { + pub year: Option, + pub month: Option, + pub day: Option, +} + +impl Date { + pub fn is_none(&self) -> bool { + self.year.is_none() + } +} diff --git a/nghe-api/src/id3/genre/mod.rs b/nghe-api/src/id3/genre/mod.rs new file mode 100644 index 000000000..0b67060be --- /dev/null +++ b/nghe-api/src/id3/genre/mod.rs @@ -0,0 +1,35 @@ +mod with_count; + +use nghe_proc_macro::api_derive; +pub use with_count::WithCount; + +#[api_derive(response = true)] +#[cfg_attr(feature = "test", derive(Clone, Hash))] +pub struct Genre { + pub name: String, +} + +#[api_derive(response = true)] +#[derive(Default)] +#[serde(transparent)] +pub struct Genres { + pub value: Vec, +} + +impl Genres { + pub fn is_empty(&self) -> bool { + self.value.is_empty() + } +} + +impl> From for Genre { + fn from(genre: S) -> Self { + Self { name: genre.into() } + } +} + +impl> FromIterator for Genres { + fn from_iter>(iter: T) -> Self { + Self { value: iter.into_iter().map(Genre::from).collect() } + } +} diff --git a/nghe-api/src/id3/genre/with_count.rs b/nghe-api/src/id3/genre/with_count.rs new file mode 100644 index 000000000..c66e97b7b --- /dev/null +++ b/nghe-api/src/id3/genre/with_count.rs @@ -0,0 +1,8 @@ +use nghe_proc_macro::api_derive; + +#[api_derive(response = true)] +pub struct WithCount { + pub value: String, + pub song_count: u32, + pub album_count: u32, +} diff --git a/nghe-api/src/id3/mod.rs b/nghe-api/src/id3/mod.rs new file mode 100644 index 000000000..b7582260d --- /dev/null +++ b/nghe-api/src/id3/mod.rs @@ -0,0 +1,22 @@ +pub mod album; +pub mod artist; +pub mod date; +pub mod genre; +pub mod song; + +pub mod builder { + pub mod artist { + pub use super::super::artist::artist_builder::*; + pub use super::super::artist::ArtistBuilder as Builder; + } + + pub mod album { + pub use super::super::album::album_builder::*; + pub use super::super::album::AlbumBuilder as Builder; + } + + pub mod song { + pub use super::super::song::song_builder::*; + pub use super::super::song::SongBuilder as Builder; + } +} diff --git a/nghe-api/src/id3/song/full.rs b/nghe-api/src/id3/song/full.rs new file mode 100644 index 000000000..a48b3754d --- /dev/null +++ b/nghe-api/src/id3/song/full.rs @@ -0,0 +1,17 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::Song; +use crate::id3::genre; + +#[serde_with::apply( + genre::Genres => #[serde(skip_serializing_if = "genre::Genres::is_empty")], +)] +#[api_derive(response = true)] +pub struct Full { + #[serde(flatten)] + pub song: Song, + pub album: String, + pub album_id: Uuid, + pub genres: genre::Genres, +} diff --git a/nghe-api/src/id3/song/mod.rs b/nghe-api/src/id3/song/mod.rs new file mode 100644 index 000000000..0e886912e --- /dev/null +++ b/nghe-api/src/id3/song/mod.rs @@ -0,0 +1,35 @@ +mod full; + +use bon::Builder; +pub use full::Full; +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::artist; + +#[serde_with::apply( + Option => #[serde(skip_serializing_if = "Option::is_none")], + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], +)] +#[api_derive(response = true)] +#[derive(Builder)] +#[builder(on(_, required))] +#[builder(state_mod(vis = "pub"))] +pub struct Song { + pub id: Uuid, + pub title: String, + pub track: Option, + pub year: Option, + pub cover_art: Option, + pub size: u32, + pub content_type: &'static str, + pub suffix: &'static str, + pub duration: u32, + pub bit_rate: u32, + pub bit_depth: Option, + pub sampling_rate: u32, + pub channel_count: u8, + pub disc_number: Option, + pub artists: Vec, + pub music_brainz_id: Option, +} diff --git a/nghe-api/src/lib.rs b/nghe-api/src/lib.rs new file mode 100644 index 000000000..fe9e26c7b --- /dev/null +++ b/nghe-api/src/lib.rs @@ -0,0 +1,17 @@ +#![feature(adt_const_params)] +#![feature(trait_alias)] + +pub mod auth; +pub mod browsing; +pub mod common; +pub mod constant; +pub mod id3; +pub mod lists; +pub mod media_annotation; +pub mod media_retrieval; +pub mod music_folder; +pub mod permission; +pub mod scan; +pub mod search; +pub mod system; +pub mod user; diff --git a/nghe-api/src/lists/get_album_list2.rs b/nghe-api/src/lists/get_album_list2.rs new file mode 100644 index 000000000..1acd61a27 --- /dev/null +++ b/nghe-api/src/lists/get_album_list2.rs @@ -0,0 +1,109 @@ +use nghe_proc_macro::api_derive; +use serde_with::serde_as; +use uuid::Uuid; + +use crate::id3; + +// TODO: Optimize this after https://github.com/serde-rs/serde/issues/1183 +#[serde_as] +#[api_derive(request = true, test_only = false)] +#[derive(Clone, Copy)] +pub struct ByYear { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub from_year: u16, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub to_year: u16, +} + +#[api_derive(request = true, copy = false)] +#[serde(tag = "type")] +#[cfg_attr(test, derive(Default))] +pub enum Type { + #[cfg_attr(test, default)] + Random, + Newest, + Frequent, + Recent, + AlphabeticalByName, + ByYear(ByYear), + ByGenre { + genre: String, + }, +} + +#[api_derive] +#[endpoint(path = "getAlbumList2")] +#[cfg_attr(test, derive(Default))] +pub struct Request { + #[serde(flatten, rename = "type")] + pub ty: Type, + pub size: Option, + pub offset: Option, + #[serde(rename = "musicFolderId")] + pub music_folder_ids: Option>, +} + +#[api_derive(response = true)] +pub struct AlbumList2 { + pub album: Vec, +} + +#[api_derive] +pub struct Response { + pub album_list2: AlbumList2, +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case("type=random", Some(Request { ty: Type::Random, ..Default::default() }))] + #[case( + "type=random&size=10", + Some(Request { ty: Type::Random, size: Some(10), ..Default::default() }) + )] + #[case("type=newest", Some(Request { ty: Type::Newest, ..Default::default() }))] + #[case("type=frequent", Some(Request { ty: Type::Frequent, ..Default::default() }))] + #[case("type=recent", Some(Request { ty: Type::Recent, ..Default::default() }))] + #[case( + "type=alphabeticalByName", + Some(Request { ty: Type::AlphabeticalByName, ..Default::default() }) + )] + #[case( + "type=byYear&fromYear=1000&toYear=2000", + Some(Request { + ty: Type::ByYear ( + ByYear{ from_year: 1000, to_year: 2000 } + ), size: None, ..Default::default() + }) + )] + #[case( + "type=byYear&fromYear=1000&toYear=2000&size=10", + Some(Request { + ty: Type::ByYear ( + ByYear{ from_year: 1000, to_year: 2000 } + ), size: Some(10), ..Default::default() + }) + )] + #[case( + "type=byGenre&genre=Test", + Some(Request { + ty: Type::ByGenre { genre: "Test".to_owned() }, size: None, ..Default::default() + }) + )] + #[case( + "type=byGenre&genre=Test&size=10", + Some(Request { + ty: Type::ByGenre { genre: "Test".to_owned() }, size: Some(10), ..Default::default() + }) + )] + #[case("type=byYear&toYear=2000", None)] + #[case("type=byYear&fromYear=From&toYear=2000", None)] + #[case("type=byGenre", None)] + fn test_deserialize(#[case] url: &str, #[case] request: Option) { + assert_eq!(serde_html_form::from_str::(url).ok(), request); + } +} diff --git a/nghe-api/src/lists/get_random_songs.rs b/nghe-api/src/lists/get_random_songs.rs new file mode 100644 index 000000000..df743490d --- /dev/null +++ b/nghe-api/src/lists/get_random_songs.rs @@ -0,0 +1,25 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "getRandomSongs")] +pub struct Request { + pub size: Option, + pub genre: Option, + pub from_year: Option, + pub to_year: Option, + #[serde(rename = "musicFolderId")] + pub music_folder_ids: Option>, +} + +#[api_derive(response = true)] +pub struct RandomSong { + pub song: Vec, +} + +#[api_derive] +pub struct Response { + pub random_songs: RandomSong, +} diff --git a/nghe-api/src/lists/mod.rs b/nghe-api/src/lists/mod.rs new file mode 100644 index 000000000..ed723d30c --- /dev/null +++ b/nghe-api/src/lists/mod.rs @@ -0,0 +1,2 @@ +pub mod get_album_list2; +pub mod get_random_songs; diff --git a/nghe-api/src/media_annotation/mod.rs b/nghe-api/src/media_annotation/mod.rs new file mode 100644 index 000000000..e8e38e71a --- /dev/null +++ b/nghe-api/src/media_annotation/mod.rs @@ -0,0 +1 @@ +pub mod scrobble; diff --git a/nghe-api/src/media_annotation/scrobble.rs b/nghe-api/src/media_annotation/scrobble.rs new file mode 100644 index 000000000..d3cb38344 --- /dev/null +++ b/nghe-api/src/media_annotation/scrobble.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "scrobble")] +pub struct Request { + #[serde(rename = "id")] + pub ids: Vec, + #[serde(rename = "id")] + pub times: Option>, + pub submission: Option, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/media_retrieval/download.rs b/nghe-api/src/media_retrieval/download.rs new file mode 100644 index 000000000..37c90e8f2 --- /dev/null +++ b/nghe-api/src/media_retrieval/download.rs @@ -0,0 +1,8 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "download", url_only = true)] +pub struct Request { + pub id: Uuid, +} diff --git a/nghe-api/src/media_retrieval/get_cover_art.rs b/nghe-api/src/media_retrieval/get_cover_art.rs new file mode 100644 index 000000000..2e84c5ef4 --- /dev/null +++ b/nghe-api/src/media_retrieval/get_cover_art.rs @@ -0,0 +1,8 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "getCoverArt", url_only = true)] +pub struct Request { + pub id: Uuid, +} diff --git a/nghe-api/src/media_retrieval/mod.rs b/nghe-api/src/media_retrieval/mod.rs new file mode 100644 index 000000000..9a6b4f78a --- /dev/null +++ b/nghe-api/src/media_retrieval/mod.rs @@ -0,0 +1,3 @@ +pub mod download; +pub mod get_cover_art; +pub mod stream; diff --git a/nghe-api/src/media_retrieval/stream.rs b/nghe-api/src/media_retrieval/stream.rs new file mode 100644 index 000000000..482e43a79 --- /dev/null +++ b/nghe-api/src/media_retrieval/stream.rs @@ -0,0 +1,50 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::common::format; + +#[api_derive(request = true, json = false)] +#[derive(Default)] +pub enum Format { + #[default] + Raw, + Transcode(format::Transcode), +} + +#[api_derive] +#[endpoint(path = "stream", url_only = true)] +#[derive(Clone, Copy)] +pub struct Request { + pub id: Uuid, + pub max_bit_rate: Option, + pub format: Option, + pub time_offset: Option, +} + +impl From for Format { + fn from(value: format::Transcode) -> Self { + Self::Transcode(value) + } +} + +mod serde { + use ::serde::{de, Deserialize, Deserializer}; + + use super::*; + + impl<'de> Deserialize<'de> for Format { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match <&'de str>::deserialize(deserializer)? { + "raw" => Ok(Self::Raw), + format => { + Ok(Self::Transcode(format.parse().map_err(|_| { + de::Error::custom("Could not parse stream format parameter") + })?)) + } + } + } + } +} diff --git a/nghe-api/src/music_folder/add.rs b/nghe-api/src/music_folder/add.rs new file mode 100644 index 000000000..0025bdf3b --- /dev/null +++ b/nghe-api/src/music_folder/add.rs @@ -0,0 +1,18 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::common::filesystem; + +#[api_derive] +#[endpoint(path = "addMusicFolder")] +pub struct Request { + pub name: String, + pub path: String, + pub ty: filesystem::Type, + pub allow: bool, +} + +#[api_derive] +pub struct Response { + pub music_folder_id: Uuid, +} diff --git a/nghe-api/src/music_folder/mod.rs b/nghe-api/src/music_folder/mod.rs new file mode 100644 index 000000000..cced7b48f --- /dev/null +++ b/nghe-api/src/music_folder/mod.rs @@ -0,0 +1 @@ +pub mod add; diff --git a/nghe-api/src/permission/add.rs b/nghe-api/src/permission/add.rs new file mode 100644 index 000000000..ee86f13e1 --- /dev/null +++ b/nghe-api/src/permission/add.rs @@ -0,0 +1,12 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "addPermission")] +pub struct Request { + pub user_id: Option, + pub music_folder_id: Option, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/permission/mod.rs b/nghe-api/src/permission/mod.rs new file mode 100644 index 000000000..cced7b48f --- /dev/null +++ b/nghe-api/src/permission/mod.rs @@ -0,0 +1 @@ +pub mod add; diff --git a/nghe-api/src/scan/mod.rs b/nghe-api/src/scan/mod.rs new file mode 100644 index 000000000..6d43dc780 --- /dev/null +++ b/nghe-api/src/scan/mod.rs @@ -0,0 +1 @@ +pub mod start; diff --git a/nghe-api/src/scan/start.rs b/nghe-api/src/scan/start.rs new file mode 100644 index 000000000..7b0b725e7 --- /dev/null +++ b/nghe-api/src/scan/start.rs @@ -0,0 +1,11 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "startScan")] +pub struct Request { + pub music_folder_id: Uuid, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/search/mod.rs b/nghe-api/src/search/mod.rs new file mode 100644 index 000000000..77e741cc0 --- /dev/null +++ b/nghe-api/src/search/mod.rs @@ -0,0 +1 @@ +pub mod search3; diff --git a/nghe-api/src/search/search3.rs b/nghe-api/src/search/search3.rs new file mode 100644 index 000000000..b24d13dcc --- /dev/null +++ b/nghe-api/src/search/search3.rs @@ -0,0 +1,33 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[endpoint(path = "search3")] +pub struct Request { + pub query: String, + pub artist_count: Option, + pub artist_offset: Option, + pub album_count: Option, + pub album_offset: Option, + pub song_count: Option, + pub song_offset: Option, + #[serde(rename = "musicFolderId")] + pub music_folder_ids: Option>, +} + +#[serde_with::apply( + Vec => #[serde(skip_serializing_if = "Vec::is_empty")], +)] +#[api_derive(response = true)] +pub struct SearchResult3 { + pub artist: Vec, + pub album: Vec, + pub song: Vec, +} + +#[api_derive] +pub struct Response { + pub search_result3: SearchResult3, +} diff --git a/nghe-api/src/system/get_open_subsonic_extensions.rs b/nghe-api/src/system/get_open_subsonic_extensions.rs new file mode 100644 index 000000000..512d0300d --- /dev/null +++ b/nghe-api/src/system/get_open_subsonic_extensions.rs @@ -0,0 +1,16 @@ +use nghe_proc_macro::api_derive; + +#[api_derive(endpoint = true, binary = false)] +#[endpoint(path = "getOpenSubsonicExtensions")] +pub struct Request {} + +#[api_derive(response = true, binary = false)] +pub struct Extension { + pub name: &'static str, + pub versions: &'static [u8], +} + +#[api_derive(binary = false)] +pub struct Response { + pub open_subsonic_extensions: &'static [Extension], +} diff --git a/nghe-api/src/system/mod.rs b/nghe-api/src/system/mod.rs new file mode 100644 index 000000000..5366b2c50 --- /dev/null +++ b/nghe-api/src/system/mod.rs @@ -0,0 +1,2 @@ +pub mod get_open_subsonic_extensions; +pub mod ping; diff --git a/nghe-api/src/system/ping.rs b/nghe-api/src/system/ping.rs new file mode 100644 index 000000000..058f15842 --- /dev/null +++ b/nghe-api/src/system/ping.rs @@ -0,0 +1,8 @@ +use nghe_proc_macro::api_derive; + +#[api_derive] +#[endpoint(path = "ping")] +pub struct Request {} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/user/create.rs b/nghe-api/src/user/create.rs new file mode 100644 index 000000000..d7bbf5b9c --- /dev/null +++ b/nghe-api/src/user/create.rs @@ -0,0 +1,19 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::Role; + +#[api_derive] +#[endpoint(path = "createUser")] +pub struct Request { + pub username: String, + pub password: String, + pub email: String, + pub role: Role, + pub allow: bool, +} + +#[api_derive] +pub struct Response { + pub user_id: Uuid, +} diff --git a/nghe-api/src/user/mod.rs b/nghe-api/src/user/mod.rs new file mode 100644 index 000000000..502d92e51 --- /dev/null +++ b/nghe-api/src/user/mod.rs @@ -0,0 +1,5 @@ +pub mod create; +mod role; +pub mod setup; + +pub use role::Role; diff --git a/nghe-api/src/user/role.rs b/nghe-api/src/user/role.rs new file mode 100644 index 000000000..a4188f264 --- /dev/null +++ b/nghe-api/src/user/role.rs @@ -0,0 +1,9 @@ +use nghe_proc_macro::api_derive; + +#[api_derive(request = true)] +pub struct Role { + pub admin: bool, + pub stream: bool, + pub download: bool, + pub share: bool, +} diff --git a/nghe-api/src/user/setup.rs b/nghe-api/src/user/setup.rs new file mode 100644 index 000000000..5bce9a9fa --- /dev/null +++ b/nghe-api/src/user/setup.rs @@ -0,0 +1,12 @@ +use nghe_proc_macro::api_derive; + +#[api_derive] +#[endpoint(path = "setupUser")] +pub struct Request { + pub username: String, + pub password: String, + pub email: String, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-backend/Cargo.toml b/nghe-backend/Cargo.toml new file mode 100644 index 000000000..c06f4c399 --- /dev/null +++ b/nghe-backend/Cargo.toml @@ -0,0 +1,101 @@ +[package] +name = "nghe_backend" +version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dependencies] +bitcode = { workspace = true } +concat-string = { workspace = true } +color-eyre = { workspace = true } +const_format = { workspace = true } +derive-new = { workspace = true } +educe = { workspace = true } +faster-hex = { workspace = true } +isolang = { workspace = true } +itertools = { workspace = true } +rand = { workspace = true } +serde_html_form = { workspace = true } +serde_with = { workspace = true } +serde = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +nghe_api = { path = "../nghe-api" } +nghe_proc_macro = { path = "../nghe-proc-macro" } + +async-walkdir = { version = "2.0.0" } +aws-config = { version = "1.5.5", default-features = false, features = [ + "rt-tokio", + "behavior-version-latest", +] } +aws-sdk-s3 = { version = "1.47.0", default-features = false, features = [ + "rt-tokio", + "sigv4a", +] } +aws-smithy-runtime = { version = "1.7.1", default-features = false, features = [ + "client", + "connector-hyper-0-14-x", +] } +axum = { version = "0.8.0-alpha.1", features = ["http2", "macros"] } +axum-extra = { version = "0.10.0-alpha.1", features = ["typed-header"] } +diesel = { version = "2.2.2", features = ["time", "uuid"] } +# TODO: remove this when diesel is released +diesel_derives = { git = "https://github.com/diesel-rs/diesel", rev = "ecf6e9215a898045ed096f905f4a0e7e4a999dba" } +diesel-async = { version = "0.5.0", features = [ + "postgres", + "deadpool", + "async-connection-wrapper", +] } +diesel_full_text_search = { version = "2.2.0", default-features = false } +diesel_migrations = { version = "2.2.0", features = ["postgres"] } +figment = { version = "0.10.19", features = ["env"] } +fs4 = { version = "0.11.1", features = ["sync"] } +futures-lite = { version = "2.3.0" } +hyper = { version = "0.14.29" } +hyper-tls = { version = "0.5.0" } +indexmap = { version = "2.5.0" } +libaes = { version = "0.7.0" } +lofty = { version = "0.21.1" } +loole = { version = "0.4.0" } +num-traits = { version = "0.2.19" } +o2o = { version = "0.4.10" } +rsmpeg = { version = "0.15.1", default-features = false, features = [ + "ffmpeg7", + "link_system_ffmpeg", +] } +tokio = { version = "1.40.0", features = [ + "fs", + "macros", + "rt-multi-thread", + "sync", +] } +tokio-util = { version = "0.7.12", features = ["io"] } +tower-http = { version = "0.6.1", features = ["cors", "trace"] } +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +typed-path = { git = "https://github.com/vnghia/typed-path", rev = "bd796e64b3cee53181a3fc2f15245f5bf731bd8c" } +unicode-normalization = { version = "0.1.24" } +xxhash-rust = { version = "0.8.10", features = ["xxh3"] } + +[dev-dependencies] +bon = { workspace = true } +fake = { workspace = true } +url = { workspace = true } +rstest = { workspace = true } + +nghe_api = { path = "../nghe-api", features = ["test"] } + +http-body-util = { version = "0.1.2" } +image = { version = "0.25.5", default-features = false, features = [ + "jpeg", + "png", +] } +tempfile = { version = "3.10.1" } + +[target.'cfg(not(any(target_env = "musl", all(target_arch = "aarch64", target_os = "linux"))))'.dev-dependencies] +diesel = { version = "2.2.2", features = ["postgres"] } diff --git a/nghe-backend/build.rs b/nghe-backend/build.rs new file mode 100644 index 000000000..690ca944f --- /dev/null +++ b/nghe-backend/build.rs @@ -0,0 +1,8 @@ +fn main() { + println!("cargo::rustc-check-cfg=cfg(hearing_test)"); + if std::env::var("NGHE_HEARING_TEST_INPUT").is_ok_and(|s| !s.is_empty()) + && std::env::var("NGHE_HEARING_TEST_OUTPUT").is_ok_and(|s| !s.is_empty()) + { + println!("cargo::rustc-cfg=hearing_test"); + } +} diff --git a/nghe-backend/migrations/.keep b/nghe-backend/migrations/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/nghe-backend/migrations/00000000000000_diesel_initial_setup/down.sql b/nghe-backend/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 000000000..d1a359fc2 --- /dev/null +++ b/nghe-backend/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +drop function if exists diesel_manage_updated_at (_tbl regclass); +drop function if exists diesel_set_updated_at (); diff --git a/nghe-backend/migrations/00000000000000_diesel_initial_setup/up.sql b/nghe-backend/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 000000000..3de2f0d62 --- /dev/null +++ b/nghe-backend/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,38 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +create or replace function diesel_manage_updated_at( + _tbl regclass +) returns void as $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ language plpgsql; + +create or replace function diesel_set_updated_at() returns trigger as $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ language plpgsql; diff --git a/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/down.sql b/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/down.sql new file mode 100644 index 000000000..58838b648 --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/down.sql @@ -0,0 +1,8 @@ +-- This file should undo anything in `up.sql` +drop function add_updated_at (_tbl regclass); + +drop function set_updated_at (); + +drop function add_updated_at_leave_scanned_at (_tbl regclass); + +drop function set_updated_at_leave_scanned_at (); diff --git a/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/up.sql b/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/up.sql new file mode 100644 index 000000000..15130655a --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-134746_create_updated_at_leave_scanned_at/up.sql @@ -0,0 +1,41 @@ +-- Your SQL goes here +create function add_updated_at(_tbl regclass) returns void as $$ +begin + execute format('create trigger set_updated_at before update on %s + for each row execute procedure set_updated_at()', _tbl); +end; +$$ language plpgsql; + +create function set_updated_at() returns trigger as $$ +begin + if ( + new is distinct from old and + new.updated_at is not distinct from old.updated_at + ) then + new.updated_at := current_timestamp; + end if; + return new; +end; +$$ language plpgsql; + +create function add_updated_at_leave_scanned_at( + _tbl regclass +) returns void as $$ +begin + execute format('create trigger set_updated_at_leave_scanned_at before update on %s + for each row execute procedure set_updated_at_leave_scanned_at()', _tbl); +end; +$$ language plpgsql; + +create function set_updated_at_leave_scanned_at() returns trigger as $$ +begin + if ( + new is distinct from old and + new.updated_at is not distinct from old.updated_at and + new.scanned_at is not distinct from old.scanned_at + ) then + new.updated_at := current_timestamp; + end if; + return new; +end; +$$ language plpgsql; diff --git a/nghe-backend/migrations/2024-01-19-144746_create_users/down.sql b/nghe-backend/migrations/2024-01-19-144746_create_users/down.sql new file mode 100644 index 000000000..5795f6b35 --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-144746_create_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table users; diff --git a/nghe-backend/migrations/2024-01-19-144746_create_users/up.sql b/nghe-backend/migrations/2024-01-19-144746_create_users/up.sql new file mode 100644 index 000000000..30bb147aa --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-144746_create_users/up.sql @@ -0,0 +1,16 @@ +-- Your SQL goes here +create table +users ( + id uuid not null default gen_random_uuid() constraint users_pkey primary key, + username text not null constraint users_username_key unique, + password bytea not null, + email text not null, + admin_role boolean not null default false, + stream_role boolean not null default false, + download_role boolean not null default false, + share_role boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +select add_updated_at('users'); diff --git a/nghe-backend/migrations/2024-01-19-153106_create_music_folders/down.sql b/nghe-backend/migrations/2024-01-19-153106_create_music_folders/down.sql new file mode 100644 index 000000000..5f6a665e7 --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-153106_create_music_folders/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table music_folders; diff --git a/nghe-backend/migrations/2024-01-19-153106_create_music_folders/up.sql b/nghe-backend/migrations/2024-01-19-153106_create_music_folders/up.sql new file mode 100644 index 000000000..8b00f07ec --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-153106_create_music_folders/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +create table +music_folders ( + id uuid not null default gen_random_uuid() constraint music_folders_pkey primary key, + path text not null constraint music_folders_path_key unique, + scanned_at timestamptz not null default now() +); diff --git a/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/down.sql b/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/down.sql new file mode 100644 index 000000000..4aad69764 --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop index user_music_folder_permissions_user_id_idx; + +drop index user_music_folder_permissions_music_folder_id_idx; + +drop table user_music_folder_permissions; diff --git a/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/up.sql b/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/up.sql new file mode 100644 index 000000000..acb188e8d --- /dev/null +++ b/nghe-backend/migrations/2024-01-19-153612_create_user_music_folder_permissions/up.sql @@ -0,0 +1,24 @@ +-- Your SQL goes here +create table +user_music_folder_permissions ( + user_id uuid not null, + music_folder_id uuid not null, + allow boolean not null default true, + constraint user_music_folder_permissions_pkey primary key ( + user_id, music_folder_id + ), + constraint user_music_folder_permissions_user_id_fkey foreign key ( + user_id + ) references users (id) on delete cascade, + constraint user_music_folder_permissions_music_folder_id_fkey foreign key ( + music_folder_id + ) references music_folders (id) on delete cascade +); + +create index user_music_folder_permissions_user_id_idx on user_music_folder_permissions ( + user_id +); + +create index user_music_folder_permissions_music_folder_id_idx on user_music_folder_permissions ( + music_folder_id +); diff --git a/nghe-backend/migrations/2024-01-20-154336_create_artists/down.sql b/nghe-backend/migrations/2024-01-20-154336_create_artists/down.sql new file mode 100644 index 000000000..f2582c367 --- /dev/null +++ b/nghe-backend/migrations/2024-01-20-154336_create_artists/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table artists; diff --git a/nghe-backend/migrations/2024-01-20-154336_create_artists/up.sql b/nghe-backend/migrations/2024-01-20-154336_create_artists/up.sql new file mode 100644 index 000000000..ff616c795 --- /dev/null +++ b/nghe-backend/migrations/2024-01-20-154336_create_artists/up.sql @@ -0,0 +1,12 @@ +-- Your SQL goes here +create table +artists ( + id uuid not null default gen_random_uuid() constraint artists_pkey primary key, + name text not null constraint artists_name_key unique, + index text not null default '?', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + scanned_at timestamptz not null default now() +); + +select add_updated_at_leave_scanned_at('artists'); diff --git a/nghe-backend/migrations/2024-01-21-012915_create_albums/down.sql b/nghe-backend/migrations/2024-01-21-012915_create_albums/down.sql new file mode 100644 index 000000000..3a7b00f23 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-012915_create_albums/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table albums; diff --git a/nghe-backend/migrations/2024-01-21-012915_create_albums/up.sql b/nghe-backend/migrations/2024-01-21-012915_create_albums/up.sql new file mode 100644 index 000000000..b1c7bf129 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-012915_create_albums/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here +create table +albums ( + id uuid not null default gen_random_uuid() constraint albums_pkey primary key, + name text not null constraint albums_name_key unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + scanned_at timestamptz not null default now() +); + +select add_updated_at_leave_scanned_at('albums'); diff --git a/nghe-backend/migrations/2024-01-21-014803_create_songs/down.sql b/nghe-backend/migrations/2024-01-21-014803_create_songs/down.sql new file mode 100644 index 000000000..521dc6c11 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-014803_create_songs/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop index songs_album_id_idx; + +drop index songs_music_folder_id_idx; + +drop table songs; diff --git a/nghe-backend/migrations/2024-01-21-014803_create_songs/up.sql b/nghe-backend/migrations/2024-01-21-014803_create_songs/up.sql new file mode 100644 index 000000000..e187fa352 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-014803_create_songs/up.sql @@ -0,0 +1,57 @@ +-- Your SQL goes here +create table +songs ( + id uuid not null default gen_random_uuid() constraint songs_pkey primary key, + -- Song tag + title text not null, + album_id uuid not null, + track_number integer default null, + track_total integer default null, + disc_number integer default null, + disc_total integer default null, + year smallint default null, + month smallint default null, + day smallint default null, + release_year smallint default null, + release_month smallint default null, + release_day smallint default null, + original_release_year smallint default null, + original_release_month smallint default null, + original_release_day smallint default null, + languages text [] not null default array[]::text [] check ( + array_position(languages, null) is null + ), + -- Song property + format text not null, + duration real not null, + bitrate integer not null, + sample_rate integer not null, + channel_count smallint not null, + -- Filesystem property + music_folder_id uuid not null, + relative_path text not null, + file_hash bigint not null, + file_size integer not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + scanned_at timestamptz not null default now(), + -- Constraints + constraint songs_album_id_fkey foreign key (album_id) references albums ( + id + ) on delete cascade, + constraint songs_music_folder_id_relative_path_key unique ( + music_folder_id, relative_path + ), + constraint songs_music_folder_id_fkey foreign key ( + music_folder_id + ) references music_folders (id) on delete cascade, + constraint songs_music_folder_id_file_hash_file_size_key unique ( + music_folder_id, file_hash, file_size + ) +); + +select add_updated_at_leave_scanned_at('songs'); + +create index songs_album_id_idx on songs (album_id); + +create index songs_music_folder_id_idx on songs (music_folder_id); diff --git a/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/down.sql b/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/down.sql new file mode 100644 index 000000000..ffb8852c5 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop index songs_album_artists_song_id_idx; + +drop index songs_album_artists_album_artist_id_idx; + +drop table songs_album_artists; diff --git a/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/up.sql b/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/up.sql new file mode 100644 index 000000000..78f816bd6 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-015739_create_songs_album_artists/up.sql @@ -0,0 +1,20 @@ +-- Your SQL goes here +create table +songs_album_artists ( + song_id uuid not null, + album_artist_id uuid not null, + upserted_at timestamptz not null default now(), + constraint songs_album_artists_pkey primary key (song_id, album_artist_id), + constraint songs_album_artists_song_id_fkey foreign key ( + song_id + ) references songs (id) on delete cascade, + constraint songs_album_artists_album_artist_id_fkey foreign key ( + album_artist_id + ) references artists (id) on delete cascade +); + +create index songs_album_artists_song_id_idx on songs_album_artists (song_id); + +create index songs_album_artists_album_artist_id_idx on songs_album_artists ( + album_artist_id +); diff --git a/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/down.sql b/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/down.sql new file mode 100644 index 000000000..867c5a3da --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop index songs_artists_song_id_idx; + +drop index songs_artists_artist_id_idx; + +drop table songs_artists; diff --git a/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/up.sql b/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/up.sql new file mode 100644 index 000000000..4099b1843 --- /dev/null +++ b/nghe-backend/migrations/2024-01-21-020718_create_songs_artists/up.sql @@ -0,0 +1,18 @@ +-- Your SQL goes here +create table +songs_artists ( + song_id uuid not null, + artist_id uuid not null, + upserted_at timestamptz not null default now(), + constraint songs_artists_pkey primary key (song_id, artist_id), + constraint songs_artists_song_id_fkey foreign key ( + song_id + ) references songs (id) on delete cascade, + constraint songs_artists_artist_id_fkey foreign key ( + artist_id + ) references artists (id) on delete cascade +); + +create index songs_artists_song_id_idx on songs_artists (song_id); + +create index songs_artists_artist_id_idx on songs_artists (artist_id); diff --git a/nghe-backend/migrations/2024-02-16-174806_create_configs/down.sql b/nghe-backend/migrations/2024-02-16-174806_create_configs/down.sql new file mode 100644 index 000000000..82b95979e --- /dev/null +++ b/nghe-backend/migrations/2024-02-16-174806_create_configs/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table configs; diff --git a/nghe-backend/migrations/2024-02-16-174806_create_configs/up.sql b/nghe-backend/migrations/2024-02-16-174806_create_configs/up.sql new file mode 100644 index 000000000..87b89ede0 --- /dev/null +++ b/nghe-backend/migrations/2024-02-16-174806_create_configs/up.sql @@ -0,0 +1,10 @@ +-- Your SQL goes here +create table +configs ( + key text not null constraint configs_pkey primary key, + text text default null, + byte bytea default null, + updated_at timestamptz not null default now() +); + +select add_updated_at('configs'); diff --git a/nghe-backend/migrations/2024-02-26-053602_create_scans/down.sql b/nghe-backend/migrations/2024-02-26-053602_create_scans/down.sql new file mode 100644 index 000000000..a0738b6cd --- /dev/null +++ b/nghe-backend/migrations/2024-02-26-053602_create_scans/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +drop index scans_is_scanning_idx; + +drop table scans; diff --git a/nghe-backend/migrations/2024-02-26-053602_create_scans/up.sql b/nghe-backend/migrations/2024-02-26-053602_create_scans/up.sql new file mode 100644 index 000000000..2881427cc --- /dev/null +++ b/nghe-backend/migrations/2024-02-26-053602_create_scans/up.sql @@ -0,0 +1,22 @@ +-- Your SQL goes here +create table +scans ( + started_at timestamptz not null default now() constraint scans_pkey primary key, + is_scanning boolean not null default true, + finished_at timestamptz default null, + scanned_count bigint not null default 0, + error_message text default null, + constraint scans_not_scanning_if_finished check ( + ( + is_scanning + and finished_at is null + ) + or ( + not is_scanning + and finished_at is not null + ) + ) +); + +create unique index scans_is_scanning_idx on scans (is_scanning) +where is_scanning; diff --git a/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/down.sql b/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/down.sql new file mode 100644 index 000000000..eec51cb18 --- /dev/null +++ b/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +alter table songs drop column cover_art_id; + +drop table cover_arts; diff --git a/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/up.sql b/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/up.sql new file mode 100644 index 000000000..077f36c52 --- /dev/null +++ b/nghe-backend/migrations/2024-03-27-204537_create_cover_arts/up.sql @@ -0,0 +1,17 @@ +-- Your SQL goes here +create table +cover_arts ( + id uuid not null default gen_random_uuid() constraint cover_arts_pkey primary key, + format text not null, + file_hash bigint not null, + file_size integer not null, + upserted_at timestamptz not null default now(), + constraint cover_arts_format_file_hash_file_size_key unique ( + format, file_hash, file_size + ) +); + +alter table songs +add column cover_art_id uuid, add constraint cover_art_id_fkey foreign key ( + cover_art_id +) references cover_arts (id) on delete set null; diff --git a/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/down.sql b/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/down.sql new file mode 100644 index 000000000..7c68c80e7 --- /dev/null +++ b/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` +alter table artists +add constraint artists_name_key unique (name); + +drop index artists_name_idx; + +alter table artists drop column mbz_id; diff --git a/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/up.sql b/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/up.sql new file mode 100644 index 000000000..5d875640b --- /dev/null +++ b/nghe-backend/migrations/2024-04-03-170326_add_artist_mbz_id/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +alter table artists add mbz_id uuid, +add constraint artists_mbz_id_key unique (mbz_id); + +create unique index artists_name_idx on artists (name) where (mbz_id is null); + +alter table artists drop constraint artists_name_key; diff --git a/nghe-backend/migrations/2024-04-04-155149_add_album_unique/down.sql b/nghe-backend/migrations/2024-04-04-155149_add_album_unique/down.sql new file mode 100644 index 000000000..f0a7e0047 --- /dev/null +++ b/nghe-backend/migrations/2024-04-04-155149_add_album_unique/down.sql @@ -0,0 +1,17 @@ +-- This file should undo anything in `up.sql` +alter table albums +add constraint albums_name_key unique (name); + +drop index albums_name_date_release_original_idx; + +alter table albums drop column mbz_id; + +alter table albums drop column year; +alter table albums drop column month; +alter table albums drop column day; +alter table albums drop column release_year; +alter table albums drop column release_month; +alter table albums drop column release_day; +alter table albums drop column original_release_year; +alter table albums drop column original_release_month; +alter table albums drop column original_release_day; diff --git a/nghe-backend/migrations/2024-04-04-155149_add_album_unique/up.sql b/nghe-backend/migrations/2024-04-04-155149_add_album_unique/up.sql new file mode 100644 index 000000000..dab87373c --- /dev/null +++ b/nghe-backend/migrations/2024-04-04-155149_add_album_unique/up.sql @@ -0,0 +1,28 @@ +-- Your SQL goes here +alter table albums add year smallint default null; +alter table albums add month smallint default null; +alter table albums add day smallint default null; +alter table albums add release_year smallint default null; +alter table albums add release_month smallint default null; +alter table albums add release_day smallint default null; +alter table albums add original_release_year smallint default null; +alter table albums add original_release_month smallint default null; +alter table albums add original_release_day smallint default null; + +alter table albums add mbz_id uuid, +add constraint albums_mbz_id_key unique (mbz_id); + +create unique index albums_name_date_release_original_idx on albums ( + name, + year, + month, + day, + release_year, + release_month, + release_day, + original_release_year, + original_release_month, + original_release_day +) nulls not distinct where (mbz_id is null); + +alter table albums drop constraint albums_name_key; diff --git a/nghe-backend/migrations/2024-04-05-232039_create_genres/down.sql b/nghe-backend/migrations/2024-04-05-232039_create_genres/down.sql new file mode 100644 index 000000000..0498cd0b1 --- /dev/null +++ b/nghe-backend/migrations/2024-04-05-232039_create_genres/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +drop table songs_genres; + +drop table genres; diff --git a/nghe-backend/migrations/2024-04-05-232039_create_genres/up.sql b/nghe-backend/migrations/2024-04-05-232039_create_genres/up.sql new file mode 100644 index 000000000..590a42372 --- /dev/null +++ b/nghe-backend/migrations/2024-04-05-232039_create_genres/up.sql @@ -0,0 +1,22 @@ +-- Your SQL goes here +create table +genres ( + id uuid not null default gen_random_uuid() constraint genres_pkey primary key, + value text not null, + upserted_at timestamptz not null default now(), + constraint genres_value_key unique (value) +); + +create table +songs_genres ( + song_id uuid not null, + genre_id uuid not null, + upserted_at timestamptz not null default now(), + constraint songs_genres_pkey primary key (song_id, genre_id), + constraint songs_genres_song_id_fkey foreign key ( + song_id + ) references songs (id) on delete cascade, + constraint songs_genres_genre_id_fkey foreign key ( + genre_id + ) references genres (id) on delete cascade +); diff --git a/nghe-backend/migrations/2024-04-06-051756_create_lyrics/down.sql b/nghe-backend/migrations/2024-04-06-051756_create_lyrics/down.sql new file mode 100644 index 000000000..995863232 --- /dev/null +++ b/nghe-backend/migrations/2024-04-06-051756_create_lyrics/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table lyrics; diff --git a/nghe-backend/migrations/2024-04-06-051756_create_lyrics/up.sql b/nghe-backend/migrations/2024-04-06-051756_create_lyrics/up.sql new file mode 100644 index 000000000..3a96fa0e1 --- /dev/null +++ b/nghe-backend/migrations/2024-04-06-051756_create_lyrics/up.sql @@ -0,0 +1,27 @@ +-- Your SQL goes here +create table +lyrics ( + song_id uuid not null, + description text not null, + language text not null, + line_values text [] not null check ( + array_position(line_values, null) is null + ), + line_starts integer [] check ( + array_position(line_starts, null) is null + ), + lyric_hash bigint not null, + lyric_size integer not null, + external bool not null, + updated_at timestamptz not null default now(), + scanned_at timestamptz not null default now(), + check (line_starts is null or array_length(line_values, 1) = array_length(line_starts, 1)), + constraint lyrics_song_id_key foreign key (song_id) references songs ( + id + ) on delete cascade, + constraint lyrics_pkey primary key ( + song_id, description, language, external + ) +); + +select add_updated_at_leave_scanned_at('lyrics'); diff --git a/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/down.sql b/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/down.sql new file mode 100644 index 000000000..7f0353c93 --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop trigger set_updated_at_leave_scanned_at on music_folders; + +alter table music_folders drop column updated_at; + +alter table music_folders drop column name; diff --git a/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/up.sql b/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/up.sql new file mode 100644 index 000000000..07b4c6ca7 --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-084031_add_music_folders_name/up.sql @@ -0,0 +1,8 @@ +-- Your SQL goes here +alter table music_folders add name text not null, +add constraint music_folders_name_key unique (name); + +alter table music_folders +add updated_at timestamptz not null default now(); + +select add_updated_at_leave_scanned_at('music_folders'); diff --git a/nghe-backend/migrations/2024-04-10-145416_create_playbacks/down.sql b/nghe-backend/migrations/2024-04-10-145416_create_playbacks/down.sql new file mode 100644 index 000000000..f308e11b1 --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-145416_create_playbacks/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table playbacks; diff --git a/nghe-backend/migrations/2024-04-10-145416_create_playbacks/up.sql b/nghe-backend/migrations/2024-04-10-145416_create_playbacks/up.sql new file mode 100644 index 000000000..6613aebc5 --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-145416_create_playbacks/up.sql @@ -0,0 +1,17 @@ +-- Your SQL goes here +create table +playbacks ( + user_id uuid not null, + song_id uuid not null, + count integer not null default 1 check (count > 0), + updated_at timestamptz not null default now(), + constraint playbacks_pkey primary key (user_id, song_id), + constraint playbacks_user_id_fkey foreign key ( + user_id + ) references users (id) on delete cascade, + constraint playbacks_song_id_fkey foreign key ( + song_id + ) references songs (id) on delete cascade +); + +select add_updated_at('playbacks'); diff --git a/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/down.sql b/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/down.sql new file mode 100644 index 000000000..cc9bd1fa9 --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table songs drop column mbz_id; diff --git a/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/up.sql b/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/up.sql new file mode 100644 index 000000000..22995d36b --- /dev/null +++ b/nghe-backend/migrations/2024-04-10-192602_add_songs_mbz_id/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +alter table songs add mbz_id uuid; diff --git a/nghe-backend/migrations/2024-04-14-171432_add_fts/down.sql b/nghe-backend/migrations/2024-04-14-171432_add_fts/down.sql new file mode 100644 index 000000000..bb1088cae --- /dev/null +++ b/nghe-backend/migrations/2024-04-14-171432_add_fts/down.sql @@ -0,0 +1,10 @@ +-- This file should undo anything in `up.sql` +alter table artists drop column ts; + +alter table albums drop column ts; + +alter table songs drop column ts; + +drop text search configuration usimple; + +drop extension unaccent; diff --git a/nghe-backend/migrations/2024-04-14-171432_add_fts/up.sql b/nghe-backend/migrations/2024-04-14-171432_add_fts/up.sql new file mode 100644 index 000000000..4fe941d5d --- /dev/null +++ b/nghe-backend/migrations/2024-04-14-171432_add_fts/up.sql @@ -0,0 +1,18 @@ +-- Your SQL goes here +create extension unaccent schema public; + +create text search configuration usimple (copy = simple); +alter text search configuration usimple +alter mapping for hword, hword_part, word with public.unaccent, simple; + +alter table artists +add column ts tsvector not null generated always as (to_tsvector('usimple', name)) stored; +create index artists_ts_idx on artists using gin (ts); + +alter table albums +add column ts tsvector not null generated always as (to_tsvector('usimple', name)) stored; +create index albums_ts_idx on albums using gin (ts); + +alter table songs +add column ts tsvector not null generated always as (to_tsvector('usimple', title)) stored; +create index songs_ts_idx on songs using gin (ts); diff --git a/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/down.sql b/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/down.sql new file mode 100644 index 000000000..d8dd55524 --- /dev/null +++ b/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/down.sql @@ -0,0 +1,33 @@ +-- This file should undo anything in `up.sql` +create unique index scans_is_scanning_idx on scans (is_scanning) +where is_scanning; +drop index scans_music_folder_id_is_scanning_idx; + +alter table scans drop column unrecoverable, +add constraint scans_not_scanning_if_finished check ( + ( + is_scanning + and finished_at is null + ) + or ( + not is_scanning + and finished_at is not null + ) +); + +alter table scans drop column scanned_song_count; +alter table scans drop column upserted_song_count; +alter table scans drop column deleted_song_count; +alter table scans drop column deleted_album_count; +alter table scans drop column deleted_artist_count; +alter table scans drop column deleted_genre_count; +alter table scans drop column scan_error_count; + +alter table scans add column error_message text, +add column scanned_count bigint not null default 0; + +alter table scans +drop constraint scans_pkey, +add constraint scans_pkey primary key (started_at); + +alter table scans drop column music_folder_id; diff --git a/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/up.sql b/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/up.sql new file mode 100644 index 000000000..854f92cf5 --- /dev/null +++ b/nghe-backend/migrations/2024-04-15-144339_add_scans_music_folder/up.sql @@ -0,0 +1,39 @@ +-- Your SQL goes here +alter table scans +add column music_folder_id uuid not null, +add constraint scans_music_folder_id foreign key (music_folder_id) references music_folders ( + id +) on delete cascade; + +alter table scans +drop constraint scans_pkey, +add constraint scans_pkey primary key (started_at, music_folder_id); + +alter table scans drop column error_message, drop column scanned_count; + +alter table scans add column scanned_song_count bigint not null default 0; +alter table scans add column upserted_song_count bigint not null default 0; +alter table scans add column deleted_song_count bigint not null default 0; +alter table scans add column deleted_album_count bigint not null default 0; +alter table scans add column deleted_artist_count bigint not null default 0; +alter table scans add column deleted_genre_count bigint not null default 0; +alter table scans add column scan_error_count bigint not null default 0; + +alter table scans add column unrecoverable bool, +drop constraint scans_not_scanning_if_finished, +add constraint scans_not_scanning_if_finished_and_set_unrecoverable check ( + ( + is_scanning + and unrecoverable is null + and finished_at is null + ) + or ( + not is_scanning + and unrecoverable is not null + and finished_at is not null + ) +); + +drop index scans_is_scanning_idx; +create unique index scans_music_folder_id_is_scanning_idx on scans (music_folder_id, is_scanning) +where is_scanning; diff --git a/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/down.sql b/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/down.sql new file mode 100644 index 000000000..537e591ed --- /dev/null +++ b/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table user_music_folder_permissions add column allow boolean not null default true; diff --git a/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/up.sql b/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/up.sql new file mode 100644 index 000000000..a631991e0 --- /dev/null +++ b/nghe-backend/migrations/2024-04-16-192827_simplify_user_music_folder_permissions/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +alter table user_music_folder_permissions drop column allow; diff --git a/nghe-backend/migrations/2024-04-19-095649_create_playlists/down.sql b/nghe-backend/migrations/2024-04-19-095649_create_playlists/down.sql new file mode 100644 index 000000000..3e9b3651d --- /dev/null +++ b/nghe-backend/migrations/2024-04-19-095649_create_playlists/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` +drop table playlists_songs; + +drop table playlists_users; + +drop table playlists; diff --git a/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql b/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql new file mode 100644 index 000000000..e303904e3 --- /dev/null +++ b/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql @@ -0,0 +1,43 @@ +-- Your SQL goes here +create table playlists ( + id uuid not null default gen_random_uuid() constraint playlists_pkey primary key, + name text not null, + comment text default null, + public boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +select add_updated_at('playlists'); + +-- access level: +-- 1 - read songs +-- 2 - add/remove songs +-- 3 - admin (add/remove users, edit, delete) +create table playlists_users ( + playlist_id uuid not null, + user_id uuid not null, + access_level smallint not null constraint playlists_users_access_level check ( + access_level between 1 and 3 + ), + constraint playlists_users_pkey primary key (playlist_id, user_id), + constraint playlists_users_playlist_id_fkey foreign key ( + playlist_id + ) references playlists (id) on delete cascade, + constraint playlists_users_user_id_fkey foreign key ( + user_id + ) references users (id) on delete cascade +); + +create table playlists_songs ( + playlist_id uuid not null, + song_id uuid not null, + created_at timestamptz not null default now(), + constraint playlists_songs_pkey primary key (playlist_id, song_id), + constraint playlists_songs_playlist_id_fkey foreign key ( + playlist_id + ) references playlists (id) on delete cascade, + constraint playlists_songs_song_id_fkey foreign key ( + song_id + ) references songs (id) on delete cascade +) diff --git a/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/down.sql b/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/down.sql new file mode 100644 index 000000000..204bb29cc --- /dev/null +++ b/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table songs drop column bit_depth; diff --git a/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/up.sql b/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/up.sql new file mode 100644 index 000000000..025d39407 --- /dev/null +++ b/nghe-backend/migrations/2024-04-22-075801_add_songs_bit_depth/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +alter table songs add column bit_depth smallint; diff --git a/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/down.sql b/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/down.sql new file mode 100644 index 000000000..7ec3c6402 --- /dev/null +++ b/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table artists alter column index set default '?'; diff --git a/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/up.sql b/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/up.sql new file mode 100644 index 000000000..8e28d0f93 --- /dev/null +++ b/nghe-backend/migrations/2024-04-22-131509_drop_artists_index_default/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +alter table artists alter column index drop default; diff --git a/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/down.sql b/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/down.sql new file mode 100644 index 000000000..d4dbcbddd --- /dev/null +++ b/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/down.sql @@ -0,0 +1,5 @@ +-- This file should undo anything in `up.sql` +alter table artists +drop column lastfm_url, +drop column lastfm_mbz_id, +drop column lastfm_biography; diff --git a/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/up.sql b/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/up.sql new file mode 100644 index 000000000..4852e939d --- /dev/null +++ b/nghe-backend/migrations/2024-04-24-171407_add_artists_lastfm_info/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +alter table artists +add column lastfm_url text, +add column lastfm_mbz_id uuid, +add column lastfm_biography text; diff --git a/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/down.sql b/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/down.sql new file mode 100644 index 000000000..d292613f1 --- /dev/null +++ b/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +alter table artists +drop column spotify_id, +drop column cover_art_id; diff --git a/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/up.sql b/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/up.sql new file mode 100644 index 000000000..eecc51ba4 --- /dev/null +++ b/nghe-backend/migrations/2024-04-25-113408_add_artists_cover_art/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +alter table artists +add column cover_art_id uuid, +add column spotify_id text, +add constraint cover_art_id_fkey foreign key ( + cover_art_id +) references cover_arts (id) on delete set null; diff --git a/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/down.sql b/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/down.sql new file mode 100644 index 000000000..b559fea36 --- /dev/null +++ b/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table music_folders drop column fs_type; diff --git a/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/up.sql b/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/up.sql new file mode 100644 index 000000000..032a59465 --- /dev/null +++ b/nghe-backend/migrations/2024-05-01-193504_add_music_folders_type/up.sql @@ -0,0 +1,8 @@ +-- Your SQL goes here +-- fs type: +-- 1 - local +-- 2 - s3 +alter table music_folders +add column fs_type smallint not null default 1 constraint music_folders_fs_type check ( + fs_type between 1 and 2 +); diff --git a/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/down.sql b/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/down.sql new file mode 100644 index 000000000..5a0c8bc26 --- /dev/null +++ b/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table songs_album_artists drop column compilation; diff --git a/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/up.sql b/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/up.sql new file mode 100644 index 000000000..17100c311 --- /dev/null +++ b/nghe-backend/migrations/2024-05-04-102554_add_album_compilation/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +alter table songs_album_artists add column compilation boolean not null default false; diff --git a/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql b/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql new file mode 100644 index 000000000..de6d203a1 --- /dev/null +++ b/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table music_folders drop column created_at; diff --git a/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql b/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql new file mode 100644 index 000000000..b2adff483 --- /dev/null +++ b/nghe-backend/migrations/2024-09-01-050600_add_music_folders_created_at/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +alter table music_folders +add column created_at timestamptz not null default now(); diff --git a/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/down.sql b/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/down.sql new file mode 100644 index 000000000..a72e91db1 --- /dev/null +++ b/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/down.sql @@ -0,0 +1,39 @@ +-- This file should undo anything in `up.sql` +drop index albums_music_folder_id_name_date_release_original_idx; + +create unique index albums_name_date_release_original_idx on albums ( + name, + year, + month, + day, + release_year, + release_month, + release_day, + original_release_year, + original_release_month, + original_release_day +) nulls not distinct where (mbz_id is null); + +alter table albums +drop constraint albums_music_folder_id_mbz_id_key, +add constraint albums_mbz_id_key unique (mbz_id); + +drop index albums_music_folder_id_idx; + +alter table albums drop column music_folder_id; + +alter table songs +add column music_folder_id uuid not null, +add constraint songs_music_folder_id foreign key (music_folder_id) references music_folders ( + id +) on delete cascade, +drop constraint songs_album_id_relative_path_key, +drop constraint songs_album_id_file_hash_file_size_key, +add constraint songs_music_folder_id_relative_path_key unique ( + music_folder_id, relative_path +), +add constraint songs_music_folder_id_file_hash_file_size_key unique ( + music_folder_id, file_hash, file_size +); + +create index songs_music_folder_id_idx on songs (music_folder_id); diff --git a/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/up.sql b/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/up.sql new file mode 100644 index 000000000..e5b1e66ef --- /dev/null +++ b/nghe-backend/migrations/2024-09-13-222721_music_folder_level_album/up.sql @@ -0,0 +1,39 @@ +-- Your SQL goes here +drop index songs_music_folder_id_idx; + +alter table songs +drop column music_folder_id, +add constraint songs_album_id_relative_path_key unique ( + album_id, relative_path +), +add constraint songs_album_id_file_hash_file_size_key unique ( + album_id, file_hash, file_size +); + +alter table albums +add column music_folder_id uuid not null, +add constraint albums_music_folder_id foreign key (music_folder_id) references music_folders ( + id +) on delete cascade; + +create index albums_music_folder_id_idx on albums (music_folder_id); + +alter table albums +drop constraint albums_mbz_id_key, +add constraint albums_music_folder_id_mbz_id_key unique (music_folder_id, mbz_id); + +drop index albums_name_date_release_original_idx; + +create unique index albums_music_folder_id_name_date_release_original_idx on albums ( + music_folder_id, + name, + year, + month, + day, + release_year, + release_month, + release_day, + original_release_year, + original_release_month, + original_release_day +) nulls not distinct where (mbz_id is null); diff --git a/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/down.sql b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/down.sql new file mode 100644 index 000000000..c77230868 --- /dev/null +++ b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/down.sql @@ -0,0 +1,9 @@ +-- This file should undo anything in `up.sql` +alter table cover_arts +drop column source, +drop column updated_at, +drop column scanned_at, +add column upserted_at timestamptz not null default now(), +add constraint cover_arts_format_file_hash_file_size_key unique ( + format, file_hash, file_size +); diff --git a/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql new file mode 100644 index 000000000..32ab8b742 --- /dev/null +++ b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here +alter table cover_arts +add column source text, +add column updated_at timestamptz not null default now(), +add column scanned_at timestamptz not null default now(), +drop column upserted_at, +add constraint cover_arts_source_file_hash_file_size_key +unique nulls not distinct ( + source, file_hash, file_size +), +drop constraint cover_arts_format_file_hash_file_size_key; diff --git a/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/down.sql b/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/down.sql new file mode 100644 index 000000000..54c8de689 --- /dev/null +++ b/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +alter table albums drop column cover_art_id; diff --git a/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/up.sql b/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/up.sql new file mode 100644 index 000000000..6a6b9a75e --- /dev/null +++ b/nghe-backend/migrations/2024-11-24-190103_add_albums_cover_art_id/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +alter table albums +add column cover_art_id uuid, add constraint cover_art_id_fkey foreign key ( + cover_art_id +) references cover_arts (id) on delete set null; diff --git a/nghe-backend/src/auth/mod.rs b/nghe-backend/src/auth/mod.rs new file mode 100644 index 000000000..02aa6d1f0 --- /dev/null +++ b/nghe-backend/src/auth/mod.rs @@ -0,0 +1,360 @@ +use axum::body::Bytes; +use axum::extract::{FromRef, FromRequest, Request}; +use axum::RequestExt; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +use nghe_api::auth::{Auth, AuthRequest}; +use nghe_api::common::{BinaryRequest, JsonRequest}; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::users; +use crate::Error; + +#[derive(Debug)] +pub struct GetUser { + pub id: Uuid, + pub request: R, +} + +#[derive(Debug)] +pub struct PostUser { + pub id: Uuid, + pub request: R, +} + +#[derive(Debug)] +pub struct BinaryUser { + pub id: Uuid, + pub request: R, +} + +trait FromIdRequest: Sized { + fn from_id_request(id: Uuid, request: R) -> Self; +} + +pub trait Authorize { + fn authorize(role: users::Role) -> Result<(), Error>; +} + +async fn authenticate( + database: &Database, + data: Auth<'_, '_>, +) -> Result { + let users::Auth { id, password, role } = users::table + .filter(users::username.eq(data.username)) + .select(users::Auth::as_select()) + .first(&mut database.get().await?) + .await + .map_err(|_| Error::Unauthenticated)?; + let password = database.decrypt(password)?; + A::authorize(role)?; + + if Auth::check(password, data.salt, &data.token) { Ok(id) } else { Err(Error::Unauthenticated) } +} + +// TODO: Optimize this after https://github.com/serde-rs/serde/issues/1183 +async fn json_authenticate>( + state: &S, + input: &str, +) -> Result +where + Database: FromRef, +{ + let auth: Auth = serde_html_form::from_str(input) + .map_err(|_| Error::SerializeAuthParameters(input.to_owned()))?; + + let database = Database::from_ref(state); + let id = authenticate::(&database, auth).await?; + + let request = serde_html_form::from_str::(input) + .map_err(|_| Error::SerializeRequestParameters(input.to_owned()))?; + + Ok(U::from_id_request(id, request)) +} + +impl FromIdRequest for GetUser { + fn from_id_request(id: Uuid, request: R) -> Self { + Self { id, request } + } +} + +impl FromIdRequest for PostUser { + fn from_id_request(id: Uuid, request: R) -> Self { + Self { id, request } + } +} + +impl FromRequest for GetUser +where + S: Send + Sync, + Database: FromRef, + R: JsonRequest + Authorize + Send, +{ + type Rejection = Error; + + #[tracing::instrument(skip_all, err)] + async fn from_request(request: Request, state: &S) -> Result { + let query = request.uri().query(); + + if NEED_AUTH { + json_authenticate(state, query.ok_or_else(|| Error::GetRequestMissingQueryParameters)?) + .await + } else { + let query = query.unwrap_or_default(); + Ok(Self { + id: Uuid::default(), + request: serde_html_form::from_str(query) + .map_err(|_| Error::SerializeRequestParameters(query.to_owned()))?, + }) + } + } +} + +impl FromRequest for PostUser +where + S: Send + Sync, + Database: FromRef, + R: JsonRequest + Authorize + Send, +{ + type Rejection = Error; + + #[tracing::instrument(skip_all, err)] + async fn from_request(request: Request, state: &S) -> Result { + json_authenticate(state, &request.extract::().await?).await + } +} + +impl FromRequest for BinaryUser +where + S: Send + Sync, + Database: FromRef, + R: BinaryRequest + Authorize + Send, +{ + type Rejection = Error; + + #[tracing::instrument(skip_all, err)] + async fn from_request(request: Request, state: &S) -> Result { + let bytes: Bytes = request.extract().await?; + + if NEED_AUTH { + let AuthRequest:: { auth, request } = + bitcode::decode(&bytes).map_err(|_| Error::SerializeBinaryRequest)?; + + let database = Database::from_ref(state); + let id = authenticate::(&database, auth).await?; + Ok(Self { id, request }) + } else { + Ok(Self { + id: Uuid::default(), + request: bitcode::decode(&bytes).map_err(|_| Error::SerializeBinaryRequest)?, + }) + } + } +} + +#[cfg(test)] +mod tests { + #![allow(unexpected_cfgs)] + + use axum::body::Body; + use axum::http; + use concat_string::concat_string; + use fake::{Fake, Faker}; + use nghe_api::common::JsonURL as _; + use nghe_proc_macro::api_derive; + use rstest::rstest; + use serde::Serialize; + + use super::*; + use crate::test::{mock, Mock}; + + #[api_derive] + #[endpoint(path = "test", same_crate = false)] + #[derive(Clone, Copy, Serialize)] + struct Request { + param_one: i32, + param_two: u32, + } + + #[api_derive] + #[allow(dead_code)] + struct Response; + + impl Authorize for Request { + fn authorize(_: users::Role) -> Result<(), Error> { + Ok(()) + } + } + + #[rstest] + #[tokio::test] + async fn test_authenticate( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + let user = mock.user(0).await; + let id = authenticate::(mock.database(), (&user.auth()).into()).await.unwrap(); + assert_eq!(id, user.id()); + } + + #[rstest] + #[tokio::test] + async fn test_authenticate_wrong_username( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + let auth = mock.user(0).await.auth(); + + let username: String = Faker.fake(); + let auth = Auth { username: &username, ..(&auth).into() }; + assert!(authenticate::(mock.database(), auth).await.is_err()); + } + + #[rstest] + #[tokio::test] + async fn test_authenticate_wrong_password( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + let auth = mock.user(0).await.auth(); + + let token = Auth::tokenize(Faker.fake::(), &auth.salt); + let auth = Auth { token, ..(&auth).into() }; + assert!(authenticate::(mock.database(), auth).await.is_err()); + } + + #[rstest] + #[tokio::test] + async fn test_json_get_auth( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + #[derive(Debug, Serialize)] + struct RequestAuth<'u, 't> { + #[serde(flatten, borrow)] + auth: Auth<'u, 't>, + #[serde(flatten)] + request: Request, + } + + let request: Request = Faker.fake(); + + let user = mock.user(0).await; + let auth = user.auth(); + let auth = (&auth).into(); + + let http_request = http::Request::builder() + .method(http::Method::GET) + .uri(concat_string!( + Request::URL, + "?", + serde_html_form::to_string(RequestAuth { auth, request }).unwrap() + )) + .body(Body::empty()) + .unwrap(); + + let test_request = + GetUser::::from_request(http_request, mock.state()).await.unwrap(); + assert_eq!(user.user.id, test_request.id); + assert_eq!(request, test_request.request); + } + + #[rstest] + #[tokio::test] + async fn test_json_get_no_auth( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + ) { + let request: Request = Faker.fake(); + + let http_request = http::Request::builder() + .method(http::Method::GET) + .uri(concat_string!(Request::URL, "?", serde_html_form::to_string(request).unwrap())) + .body(Body::empty()) + .unwrap(); + + let test_request = + GetUser::::from_request(http_request, mock.state()).await.unwrap(); + assert_eq!(Uuid::default(), test_request.id); + assert_eq!(request, test_request.request); + } + + #[rstest] + #[tokio::test] + async fn test_json_post( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + let request: Request = Faker.fake(); + + let user = mock.user(0).await; + let auth = user.auth(); + let auth = (&auth).into(); + + let http_request = http::Request::builder() + .method(http::Method::POST) + .uri(Request::URL) + .body(Body::from(concat_string!( + serde_html_form::to_string::(auth).unwrap(), + "&", + serde_html_form::to_string(request).unwrap() + ))) + .unwrap(); + + let test_request = + PostUser::::from_request(http_request, mock.state()).await.unwrap(); + assert_eq!(user.user.id, test_request.id); + assert_eq!(request, test_request.request); + } + + #[rstest] + #[tokio::test] + async fn test_binary_auth( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + let request: Request = Faker.fake(); + + let user = mock.user(0).await; + let auth = user.auth(); + let auth = (&auth).into(); + + let http_request = http::Request::builder() + .method(http::Method::POST) + .body(Body::from(bitcode::encode(&AuthRequest { auth, request }))) + .unwrap(); + + let test_request = + BinaryUser::::from_request(http_request, mock.state()).await.unwrap(); + assert_eq!(user.user.id, test_request.id); + assert_eq!(request, test_request.request); + } + + #[rstest] + #[tokio::test] + async fn test_binary_no_auth( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + ) { + let request: Request = Faker.fake(); + + let http_request = http::Request::builder() + .method(http::Method::POST) + .body(Body::from(bitcode::encode(&request))) + .unwrap(); + + let test_request = + BinaryUser::::from_request(http_request, mock.state()).await.unwrap(); + assert_eq!(Uuid::default(), test_request.id); + assert_eq!(request, test_request.request); + } +} diff --git a/nghe-backend/src/config/cover_art.rs b/nghe-backend/src/config/cover_art.rs new file mode 100644 index 000000000..c78b2eb7c --- /dev/null +++ b/nghe-backend/src/config/cover_art.rs @@ -0,0 +1,38 @@ +use educe::Educe; +use serde::{Deserialize, Serialize}; +use serde_with::formats::SpaceSeparator; +use serde_with::{serde_as, StringWithSeparator}; +use typed_path::utils::utf8_temp_dir; +use typed_path::Utf8NativePathBuf; + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct CoverArt { + #[serde(with = "crate::filesystem::path::serde::option")] + #[educe(Default( + expression = Some(utf8_temp_dir().unwrap().join("nghe").join("cache").join("cover_art")) + ))] + pub dir: Option, + #[serde_as(as = "StringWithSeparator::")] + #[educe(Default(expression = vec!["cover.jpg".to_owned(), "cover.png".to_owned()]))] + pub names: Vec, +} + +#[cfg(test)] +mod test { + use strum::IntoEnumIterator; + use typed_path::Utf8NativePath; + + use super::*; + use crate::file::picture; + + impl CoverArt { + pub fn with_prefix(self, prefix: impl AsRef) -> Self { + Self { + dir: self.dir.map(|_| prefix.as_ref().join("cache").join("cover_art")), + names: picture::Format::iter().map(picture::Format::name).collect(), + } + } + } +} diff --git a/nghe-backend/src/config/database.rs b/nghe-backend/src/config/database.rs new file mode 100644 index 000000000..b850ff582 --- /dev/null +++ b/nghe-backend/src/config/database.rs @@ -0,0 +1,16 @@ +use educe::Educe; +use serde::Deserialize; +use serde_with::serde_as; + +use crate::database::Key; + +#[serde_as] +#[derive(Deserialize, Educe)] +#[educe(Debug)] +pub struct Database { + #[educe(Debug(ignore))] + pub url: String, + #[serde_as(as = "serde_with::hex::Hex")] + #[educe(Debug(ignore))] + pub key: Key, +} diff --git a/nghe-backend/src/config/filesystem.rs b/nghe-backend/src/config/filesystem.rs new file mode 100644 index 000000000..a8bd77dc7 --- /dev/null +++ b/nghe-backend/src/config/filesystem.rs @@ -0,0 +1,67 @@ +use educe::Educe; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +#[serde_as] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Educe)] +#[educe(Default)] +pub struct Scan { + // 100 KiB in bytes + #[educe(Default(expression = 100 * 1024))] + pub minimum_size: usize, + #[serde_as(deserialize_as = "serde_with::DefaultOnError")] + #[educe(Default(expression = Some(10)))] + pub channel_size: Option, + #[educe(Default(expression = 10))] + pub pool_size: usize, +} + +#[derive(Debug, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct Tls { + #[educe(Default(expression = false))] + pub accept_invalid_certs: bool, + #[educe(Default(expression = false))] + pub accept_invalid_hostnames: bool, +} + +#[derive(Debug, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct S3 { + #[educe(Default(expression = + std::env::var("AWS_ACCESS_KEY_ID").is_ok() && std::env::var("AWS_SECRET_ACCESS_KEY").is_ok() + ))] + pub enable: bool, + #[educe(Default(expression = std::env::var("AWS_USE_PATH_STYLE_ENDPOINT").is_ok()))] + pub use_path_style_endpoint: bool, + #[educe(Default(expression = 15))] + pub presigned_duration: u64, + #[educe(Default(expression = 0))] + pub stalled_stream_grace_preriod: u64, + #[educe(Default(expression = 5))] + pub connect_timeout: u64, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Filesystem { + pub scan: Scan, + pub tls: Tls, + pub s3: S3, +} + +#[cfg(test)] +mod test { + use super::*; + + impl S3 { + pub fn test() -> Self { + Self { enable: true, ..Self::default() } + } + } + + impl Filesystem { + pub fn test() -> Self { + Self { s3: S3::test(), ..Self::default() } + } + } +} diff --git a/nghe-backend/src/config/index.rs b/nghe-backend/src/config/index.rs new file mode 100644 index 000000000..09595d5df --- /dev/null +++ b/nghe-backend/src/config/index.rs @@ -0,0 +1,63 @@ +use std::borrow::Cow; + +use educe::Educe; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +use crate::{database, Error}; + +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct Index { + #[serde(with = "split")] + #[educe(Default(expression = Index::split("The An A Die Das Ein Eine Les Le La")))] + pub ignore_prefixes: Vec, +} + +impl Index { + fn split(s: &str) -> Vec { + s.split_ascii_whitespace().map(|v| concat_string::concat_string!(v, " ")).collect() + } + + fn merge(prefixes: &[impl AsRef]) -> Result { + Ok(prefixes + .iter() + .map(|prefix| prefix.as_ref().strip_suffix(' ')) + .collect::>>() + .ok_or_else(|| Error::ConfigIndexIgnorePrefixEndWithoutSpace)? + .iter() + .join(" ")) + } +} + +impl database::Config for Index { + const KEY: &'static str = "ignored_articles"; + + const ENCRYPTED: bool = false; + + fn value(&self) -> Result, Error> { + Self::merge(&self.ignore_prefixes).map(String::into) + } +} + +mod split { + use serde::ser::Error; + use serde::{Deserialize, Deserializer, Serializer}; + + use super::*; + + pub fn serialize(prefixes: &[String], serializer: S) -> Result + where + S: Serializer, + { + serializer + .serialize_str(&Index::merge(prefixes).map_err(|e| S::Error::custom(e.to_string()))?) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Ok(Index::split(&::deserialize(deserializer)?)) + } +} diff --git a/nghe-backend/src/config/mod.rs b/nghe-backend/src/config/mod.rs new file mode 100644 index 000000000..02ff65a3a --- /dev/null +++ b/nghe-backend/src/config/mod.rs @@ -0,0 +1,45 @@ +mod cover_art; +mod database; +pub mod filesystem; +mod index; +pub mod parsing; +mod server; +mod transcode; + +pub use cover_art::CoverArt; +pub use database::Database; +use figment::providers::{Env, Serialized}; +use figment::Figment; +use filesystem::Filesystem; +pub use index::Index; +use nghe_api::constant; +pub use parsing::Parsing; +use serde::Deserialize; +pub use server::Server; +pub use transcode::Transcode; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub server: Server, + pub database: Database, + pub filesystem: Filesystem, + pub parsing: Parsing, + pub index: Index, + pub transcode: Transcode, + pub cover_art: CoverArt, +} + +impl Default for Config { + fn default() -> Self { + Figment::new() + .merge(Env::prefixed(const_format::concatc!(constant::SERVER_NAME, "_")).split("__")) + .join(Serialized::default("server", Server::default())) + .join(Serialized::default("filesystem", Filesystem::default())) + .join(Serialized::default("parsing", Parsing::default())) + .join(Serialized::default("index", Index::default())) + .join(Serialized::default("transcode", Transcode::default())) + .join(Serialized::default("cover_art", CoverArt::default())) + .extract() + .expect("Could not parse config") + } +} diff --git a/nghe-backend/src/config/parsing/mod.rs b/nghe-backend/src/config/parsing/mod.rs new file mode 100644 index 000000000..189e44501 --- /dev/null +++ b/nghe-backend/src/config/parsing/mod.rs @@ -0,0 +1,20 @@ +pub mod vorbis_comments; + +use serde::{Deserialize, Serialize}; +use vorbis_comments::VorbisComments; + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Parsing { + pub vorbis_comments: VorbisComments, +} + +#[cfg(test)] +mod test { + use super::*; + + impl Parsing { + pub fn test() -> Self { + Self { vorbis_comments: VorbisComments::test() } + } + } +} diff --git a/nghe-backend/src/config/parsing/vorbis_comments.rs b/nghe-backend/src/config/parsing/vorbis_comments.rs new file mode 100644 index 000000000..c6ca89b9b --- /dev/null +++ b/nghe-backend/src/config/parsing/vorbis_comments.rs @@ -0,0 +1,112 @@ +use educe::Educe; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Common { + pub name: String, + #[serde_as(as = "serde_with::NoneAsEmptyString")] + pub date: Option, + #[serde_as(as = "serde_with::NoneAsEmptyString")] + pub release_date: Option, + #[serde_as(as = "serde_with::NoneAsEmptyString")] + pub original_release_date: Option, + pub mbz_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artist { + pub name: String, + pub mbz_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct Artists { + #[educe(Default(expression = Artist::default_song()))] + pub song: Artist, + #[educe(Default(expression = Artist::default_album()))] + pub album: Artist, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct TrackDisc { + #[educe(Default(expression = "TRACKNUMBER".into()))] + pub track_number: String, + #[educe(Default(expression = "TRACKTOTAL".into()))] + pub track_total: String, + #[educe(Default(expression = "DISCNUMBER".into()))] + pub disc_number: String, + #[educe(Default(expression = "DISCTOTAL".into()))] + pub disc_total: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct VorbisComments { + #[educe(Default(expression = Common::default_song()))] + pub song: Common, + #[educe(Default(expression = Common::default_album()))] + pub album: Common, + pub artists: Artists, + pub track_disc: TrackDisc, + #[educe(Default(expression = "LANGUAGE".into()))] + pub languages: String, + #[educe(Default(expression = "GENRE".into()))] + pub genres: String, + #[educe(Default(expression = "COMPILATION".into()))] + pub compilation: String, +} + +impl Common { + fn default_song() -> Self { + Self { + name: "TITLE".into(), + date: None, + release_date: None, + original_release_date: None, + mbz_id: "MUSICBRAINZ_RELEASETRACKID".into(), + } + } + + fn default_album() -> Self { + Self { + name: "ALBUM".into(), + date: Some("DATE".into()), + release_date: Some("RELEASEDATE".into()), + original_release_date: Some("ORIGYEAR".into()), + mbz_id: "MUSICBRAINZ_ALBUMID".into(), + } + } +} + +impl Artist { + fn default_song() -> Self { + Self { name: "ARTIST".into(), mbz_id: "MUSICBRAINZ_ARTISTID".into() } + } + + fn default_album() -> Self { + Self { name: "ALBUMARTIST".into(), mbz_id: "MUSICBRAINZ_ALBUMARTISTID".into() } + } +} + +#[cfg(test)] +mod test { + use super::*; + + impl VorbisComments { + pub fn test() -> Self { + Self { + song: Common { + date: Some("SDATE".into()), + release_date: Some("SRELEASEDATE".into()), + original_release_date: Some("SORIGYEAR".into()), + ..Common::default_song() + }, + ..Self::default() + } + } + } +} diff --git a/nghe-backend/src/config/server.rs b/nghe-backend/src/config/server.rs new file mode 100644 index 000000000..19e144a7a --- /dev/null +++ b/nghe-backend/src/config/server.rs @@ -0,0 +1,24 @@ +use std::net::{IpAddr, SocketAddr}; + +use educe::Educe; +use serde::{Deserialize, Serialize}; +use typed_path::utils::utf8_current_dir; +use typed_path::Utf8NativePathBuf; + +#[derive(Debug, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct Server { + #[educe(Default(expression = [127u8, 0u8, 0u8, 1u8].into()))] + pub host: IpAddr, + #[educe(Default(expression = 3000))] + pub port: u16, + #[serde(with = "crate::filesystem::path::serde")] + #[educe(Default(expression = utf8_current_dir().unwrap().join("frontend").join("dist")))] + pub frontend_dir: Utf8NativePathBuf, +} + +impl Server { + pub fn to_socket_addr(&self) -> SocketAddr { + SocketAddr::new(self.host, self.port) + } +} diff --git a/nghe-backend/src/config/transcode.rs b/nghe-backend/src/config/transcode.rs new file mode 100644 index 000000000..a264b41b4 --- /dev/null +++ b/nghe-backend/src/config/transcode.rs @@ -0,0 +1,37 @@ +use educe::Educe; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use typed_path::utils::utf8_temp_dir; +use typed_path::Utf8NativePathBuf; + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, Educe)] +#[educe(Default)] +pub struct Transcode { + #[educe(Default(expression = 32 * 1024))] + pub buffer_size: usize, + #[serde_as(deserialize_as = "serde_with::DefaultOnError")] + #[educe(Default(expression = Some(10)))] + pub channel_size: Option, + #[serde(with = "crate::filesystem::path::serde::option")] + #[educe(Default( + expression = Some(utf8_temp_dir().unwrap().join("nghe").join("cache").join("transcode")) + ))] + pub cache_dir: Option, +} + +#[cfg(test)] +mod test { + use typed_path::Utf8NativePath; + + use super::*; + + impl Transcode { + pub fn with_prefix(self, prefix: impl AsRef) -> Self { + Self { + cache_dir: self.cache_dir.map(|_| prefix.as_ref().join("cache").join("transcode")), + ..self + } + } + } +} diff --git a/nghe-backend/src/database/config.rs b/nghe-backend/src/database/config.rs new file mode 100644 index 000000000..f833b46bc --- /dev/null +++ b/nghe-backend/src/database/config.rs @@ -0,0 +1,138 @@ +use std::borrow::Cow; + +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; + +use super::Database; +use crate::orm::configs; +use crate::Error; + +pub trait Config { + const KEY: &'static str; + const ENCRYPTED: bool; + + fn value(&self) -> Result, Error>; +} + +impl Database { + pub async fn upsert_config(&self, config: &C) -> Result<(), Error> { + let value = config.value()?; + let data = if C::ENCRYPTED { + let value: &str = value.as_ref(); + configs::Data { text: None, byte: Some(self.encrypt(value).into()) } + } else { + configs::Data { text: Some(value), byte: None } + }; + let upsert = configs::Upsert { key: C::KEY, data }; + + diesel::insert_into(configs::table) + .values(&upsert) + .on_conflict(configs::key) + .do_update() + .set(&upsert) + .execute(&mut self.get().await?) + .await?; + Ok(()) + } + + pub async fn get_config(&self) -> Result { + let config = configs::table + .filter(configs::key.eq(C::KEY)) + .select(configs::Data::as_select()) + .get_result(&mut self.get().await?) + .await?; + + if C::ENCRYPTED { + String::from_utf8( + self.decrypt( + config.byte.ok_or_else(|| Error::DatabaseInvalidConfigFormat(C::KEY))?, + )?, + ) + .map_err(Error::from) + } else { + config + .text + .ok_or_else(|| Error::DatabaseInvalidConfigFormat(C::KEY)) + .map(Cow::into_owned) + } + } +} + +#[cfg(test)] +mod tests { + use fake::{Dummy, Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[derive(Dummy)] + struct NonEncryptedConfig(String); + + impl Config for NonEncryptedConfig { + const ENCRYPTED: bool = false; + const KEY: &'static str = "non-encrypted"; + + fn value(&self) -> Result, Error> { + Ok((&self.0).into()) + } + } + + #[derive(Dummy)] + struct EncryptedConfig(String); + + impl Config for EncryptedConfig { + const ENCRYPTED: bool = true; + const KEY: &'static str = "encrypted"; + + fn value(&self) -> Result, Error> { + Ok((&self.0).into()) + } + } + + #[rstest] + #[tokio::test] + async fn test_roundtrip_non_encrypted( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(true, false)] update: bool, + ) { + let database = mock.database(); + + let config: NonEncryptedConfig = Faker.fake(); + database.upsert_config(&config).await.unwrap(); + let database_config = database.get_config::().await.unwrap(); + assert_eq!(database_config, config.0); + + if update { + let update: NonEncryptedConfig = Faker.fake(); + database.upsert_config(&update).await.unwrap(); + let database_update = database.get_config::().await.unwrap(); + assert_eq!(database_update, update.0); + } + } + + #[rstest] + #[tokio::test] + async fn test_roundtrip_encrypted( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(true, false)] update: bool, + ) { + let database = mock.database(); + + let config: EncryptedConfig = Faker.fake(); + database.upsert_config(&config).await.unwrap(); + let database_config = database.get_config::().await.unwrap(); + assert_eq!(database_config, config.0); + + if update { + let update: EncryptedConfig = Faker.fake(); + database.upsert_config(&update).await.unwrap(); + let database_update = database.get_config::().await.unwrap(); + assert_eq!(database_update, update.0); + } + } +} diff --git a/nghe-backend/src/database/mod.rs b/nghe-backend/src/database/mod.rs new file mode 100644 index 000000000..9a1346551 --- /dev/null +++ b/nghe-backend/src/database/mod.rs @@ -0,0 +1,77 @@ +mod config; + +use diesel_async::pooled_connection::{deadpool, AsyncDieselConnectionManager}; +use diesel_async::AsyncPgConnection; +use libaes::Cipher; + +use crate::Error; + +type Connection = AsyncDieselConnectionManager; +type Pool = deadpool::Pool; + +pub type Key = [u8; libaes::AES_128_KEY_LEN]; + +pub use config::Config; + +#[derive(Clone)] +pub struct Database { + pool: Pool, + key: Key, +} + +impl Database { + const IV_LEN: usize = 16; + + pub fn new(config: &crate::config::Database) -> Self { + let pool = Pool::builder(Connection::new(&config.url)) + .build() + .expect("Could not build database connection pool"); + Self { pool, key: config.key } + } + + pub async fn get(&self) -> Result, Error> { + self.pool.get().await.map_err(|_| Error::CheckoutConnectionPool) + } + + pub fn encrypt(&self, data: impl AsRef<[u8]>) -> Vec { + Self::encrypt_impl(&self.key, data) + } + + pub fn decrypt(&self, data: impl AsRef<[u8]>) -> Result, Error> { + Self::decrypt_impl(&self.key, data) + } + + fn encrypt_impl(key: &Key, data: impl AsRef<[u8]>) -> Vec { + let data = data.as_ref(); + + let iv: [u8; Self::IV_LEN] = rand::random(); + [iv.as_slice(), Cipher::new_128(key).cbc_encrypt(&iv, data).as_slice()].concat() + } + + fn decrypt_impl(key: &Key, data: impl AsRef<[u8]>) -> Result, Error> { + let data = data.as_ref(); + + let cipher_text = &data[Self::IV_LEN..]; + let iv = &data[..Self::IV_LEN]; + + let output = Cipher::new_128(key).cbc_decrypt(iv, cipher_text); + if output.is_empty() { Err(Error::DecryptDatabaseValue) } else { Ok(output) } + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + + use super::*; + + #[test] + fn test_roundtrip() { + let key: Key = Faker.fake(); + let data = (16..32).fake::().into_bytes(); + assert_eq!( + data, + Database::decrypt_impl(&key, Database::encrypt_impl(&key, &data)).unwrap() + ); + } +} diff --git a/nghe-backend/src/error/mod.rs b/nghe-backend/src/error/mod.rs new file mode 100644 index 000000000..d75dd0649 --- /dev/null +++ b/nghe-backend/src/error/mod.rs @@ -0,0 +1,169 @@ +use std::ffi::OsString; + +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::operation::get_object::GetObjectError; +use aws_sdk_s3::operation::head_object::HeadObjectError; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use loole::SendError; +use o2o::o2o; + +#[derive(Debug, thiserror::Error, o2o)] +#[from_owned(std::io::Error| repeat(), return Self::Internal(@.into()))] +#[from_owned(isolang::ParseLanguageError)] +#[from_owned(uuid::Error)] +#[from_owned(lofty::error::LoftyError)] +#[from_owned(aws_sdk_s3::primitives::ByteStreamError)] +#[from_owned(std::num::TryFromIntError)] +#[from_owned(typed_path::StripPrefixError)] +#[from_owned(time::error::ComponentRange)] +#[from_owned(tokio::task::JoinError)] +#[from_owned(tokio::sync::AcquireError)] +#[from_owned(SdkError)] +#[from_owned(SdkError)] +#[from_owned(rsmpeg::error::RsmpegError)] +#[from_owned(std::ffi::NulError)] +#[from_owned(std::str::Utf8Error)] +#[from_owned(aws_sdk_s3::presigning::PresigningConfigError)] +#[from_owned(std::string::FromUtf8Error)] +#[from_owned(axum::extract::rejection::StringRejection)] +#[from_owned(strum::ParseError)] +pub enum Error { + #[error("{0}")] + InvalidParameter(&'static str), + #[error("Could not serialize get request with no query parameters")] + GetRequestMissingQueryParameters, + #[error("Could not serialize auth parameters with query {0}")] + SerializeAuthParameters(String), + #[error("Could not serialize request parameters with query {0}")] + SerializeRequestParameters(String), + #[error("Could not serialize binary request")] + SerializeBinaryRequest, + #[error(transparent)] + ExtractRequestBody(#[from] axum::extract::rejection::BytesRejection), + #[error("Scrobble request must have more id than time")] + ScrobbleRequestMustHaveBeMoreIdThanTime, + + #[error("Resource not found")] + NotFound, + + #[error("Could not checkout a connection from connection pool")] + CheckoutConnectionPool, + #[error("Could not decrypt value from database")] + DecryptDatabaseValue, + #[error("Language from database should not be null")] + LanguageFromDatabaseIsNull, + #[error("Inconsistency encountered while querying database for scan process")] + DatabaseScanQueryInconsistent, + #[error("Invalid config format for key {0}")] + DatabaseInvalidConfigFormat(&'static str), + #[error("Song duration is empty")] + DatabaseSongDurationIsEmpty, + + #[error("{0}")] + Unauthorized(&'static str), + #[error("Could not login due to bad credentials")] + Unauthenticated, + #[error("You need to have {0} role to perform this action")] + MissingRole(&'static str), + #[error("Range header is invalid")] + InvalidRangeHeader, + + #[error("Could not parse date from {0:?}")] + MediaDateFormat(String), + #[error( + "Could not parse position from track number {track_number:?}, track total \ + {track_total:?}, disc number {disc_number:?} and disc total {disc_total:?}" + )] + MediaPositionFormat { + track_number: Option, + track_total: Option, + disc_number: Option, + disc_total: Option, + }, + #[error("There should not be more musicbrainz id than artist name")] + MediaArtistMbzIdMoreThanArtistName, + #[error("Song artist should not be empty")] + MediaSongArtistEmpty, + #[error("Artist name should not be empty")] + MediaArtistNameEmpty, + #[error("Could not read vorbis comments from flac file")] + MediaFlacMissingVorbisComments, + #[error("Could not find audio track in the media file")] + MediaAudioTrackMissing, + #[error("Media picture format is missing")] + MediaPictureMissingFormat, + #[error("Media picture format {0} is unsupported")] + MediaPictureUnsupportedFormat(String), + #[error("Media cover art dir is not enabled")] + MediaCoverArtDirIsNotEnabled, + + #[error("Transcode output format is not supported")] + TranscodeOutputFormatNotSupported, + #[error("Encoder sample fmts are missing")] + TranscodeEncoderSampleFmtsMissing, + #[error("Could not get {0} av filter")] + TranscodeAVFilterMissing(&'static str), + #[error("Name is missing for sample format {0}")] + TranscodeSampleFmtNameMissing(i32), + + #[error("Path extension is missing")] + PathExtensionMissing, + #[error("Absolute file path does not have parent directory")] + AbsoluteFilePathDoesNotHaveParentDirectory, + #[error("S3 path is not an absolute unix path: {0}")] + FilesystemS3InvalidPath(String), + #[error("S3 object does not have size information")] + FilesystemS3MissingObjectSize, + #[error("Non UTF-8 path encountered: {0:?}")] + FilesystemLocalNonUTF8PathEncountered(OsString), + #[error("Typed path has wrong platform information")] + FilesystemTypedPathWrongPlatform, + + #[error("Prefix does not end with whitespace")] + ConfigIndexIgnorePrefixEndWithoutSpace, + + #[error("Could not convert float to integer with value {0}")] + CouldNotConvertFloatToInteger(f32), + + #[error(transparent)] + Internal(#[from] color_eyre::Report), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let (status_code, status_message) = match &self { + Error::InvalidParameter(_) + | Error::GetRequestMissingQueryParameters + | Error::SerializeAuthParameters(_) + | Error::SerializeRequestParameters(_) + | Error::SerializeBinaryRequest + | Error::InvalidRangeHeader => (StatusCode::BAD_REQUEST, self.to_string()), + Error::ExtractRequestBody(_) => { + (StatusCode::BAD_REQUEST, "Could not extract request body".into()) + } + Error::Unauthenticated => (StatusCode::FORBIDDEN, self.to_string()), + Error::Unauthorized(_) | Error::MissingRole(_) => { + (StatusCode::UNAUTHORIZED, self.to_string()) + } + Error::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into()), + }; + (status_code, status_message).into_response() + } +} + +impl From> for Error { + fn from(value: SendError) -> Self { + Self::Internal(value.into()) + } +} + +impl From for Error { + fn from(value: diesel::result::Error) -> Self { + match value { + diesel::result::Error::NotFound => Error::NotFound, + _ => Error::Internal(value.into()), + } + } +} diff --git a/nghe-backend/src/file/audio/artist.rs b/nghe-backend/src/file/audio/artist.rs new file mode 100644 index 000000000..1ebe2864f --- /dev/null +++ b/nghe-backend/src/file/audio/artist.rs @@ -0,0 +1,629 @@ +use std::borrow::{Borrow, Cow}; + +use diesel::dsl::{exists, not}; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +#[cfg(test)] +use fake::{Dummy, Fake, Faker}; +use futures_lite::{stream, StreamExt}; +use indexmap::IndexSet; +use o2o::o2o; +use unicode_normalization::UnicodeNormalization; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::upsert::Insert as _; +use crate::orm::{artists, songs_album_artists, songs_artists}; +use crate::Error; + +#[derive(Debug, PartialEq, Eq, Hash, o2o)] +#[from_owned(artists::Data<'a>)] +#[ref_into(artists::Data<'a>)] +#[cfg_attr(test, derive(Dummy, Clone, PartialOrd, Ord))] +pub struct Artist<'a> { + #[ref_into(~.as_str().into())] + #[cfg_attr(test, dummy(expr = "Faker.fake::().into()"))] + pub name: Cow<'a, str>, + pub mbz_id: Option, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(Dummy, Eq, Clone))] +pub struct Artists<'a> { + #[cfg_attr(test, dummy(expr = "fake::vec![Artist; 1..5].into_iter().collect()"))] + pub song: IndexSet>, + #[cfg_attr(test, dummy(expr = "fake::vec![Artist; 0..3].into_iter().collect()"))] + pub album: IndexSet>, + pub compilation: bool, +} + +impl<'a> Artist<'a> { + pub fn index(&self, prefixes: &[impl AsRef]) -> Result { + let mut iter = prefixes.iter(); + let name = loop { + match iter.next() { + Some(prefix) => { + if let Some(name) = self.name.strip_prefix(prefix.as_ref()) { + break name; + } + } + None => break self.name.as_ref(), + } + }; + name.nfkd().next().ok_or_else(|| Error::MediaArtistNameEmpty).map(|c| { + if c.is_ascii_alphabetic() { + c.to_ascii_uppercase() + } else if c.is_numeric() { + '#' + } else if !c.is_alphabetic() { + '*' + } else { + c + } + }) + } + + pub async fn upsert( + &self, + database: &Database, + prefixes: &[impl AsRef], + ) -> Result { + artists::Upsert { index: self.index(prefixes)?.to_string().into(), data: self.into() } + .insert(database) + .await + } + + async fn upserts + 'a>( + database: &Database, + artists: impl IntoIterator, + prefixes: &[impl AsRef], + ) -> Result, Error> { + stream::iter(artists) + .then(async |artist| artist.borrow().upsert(database, prefixes).await) + .try_collect() + .await + } +} + +impl<'a> Artists<'a> { + pub fn new( + song: impl IntoIterator>, + album: impl IntoIterator>, + compilation: bool, + ) -> Result { + let song: IndexSet<_> = song.into_iter().collect(); + let album = album.into_iter().collect(); + if song.is_empty() { + Err(Error::MediaSongArtistEmpty) + } else { + Ok(Self { song, album, compilation }) + } + } + + pub fn song(&self) -> &IndexSet> { + &self.song + } + + pub fn album(&self) -> &IndexSet> { + if self.album.is_empty() { &self.song } else { &self.album } + } + + pub fn compilation(&self) -> bool { + // If the song has compilation, all artists in the song artists will be added to song + // album artists with compilation set to true. If it also contains any album + // artists, the compilation field will be overwritten to false later. If the + // album artists field is empty, the album artists will be the same with + // song artists which then set any compilation field to false, so no need to add them in + // the first place. + if self.album.is_empty() || self.song.difference(&self.album).peekable().peek().is_none() { + false + } else { + self.compilation + } + } + + pub async fn upsert_song_artist( + database: &Database, + song_id: Uuid, + artist_ids: &[Uuid], + ) -> Result<(), Error> { + diesel::insert_into(songs_artists::table) + .values::>( + artist_ids + .iter() + .copied() + .map(|artist_id| songs_artists::Data { song_id, artist_id }) + .collect(), + ) + .on_conflict((songs_artists::song_id, songs_artists::artist_id)) + .do_update() + .set(songs_artists::upserted_at.eq(time::OffsetDateTime::now_utc())) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + + pub async fn upsert_song_album_artist( + database: &Database, + song_id: Uuid, + album_artist_ids: &[Uuid], + compilation: bool, + ) -> Result<(), Error> { + diesel::insert_into(songs_album_artists::table) + .values::>( + album_artist_ids + .iter() + .copied() + .map(|album_artist_id| songs_album_artists::Data { + song_id, + album_artist_id, + compilation, + }) + .collect(), + ) + .on_conflict((songs_album_artists::song_id, songs_album_artists::album_artist_id)) + .do_update() + .set(( + songs_album_artists::compilation.eq(compilation), + songs_album_artists::upserted_at.eq(time::OffsetDateTime::now_utc()), + )) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + + pub async fn upsert( + &self, + database: &Database, + prefixes: &[impl AsRef], + song_id: Uuid, + ) -> Result<(), Error> { + let song_artist_ids = Artist::upserts(database, &self.song, prefixes).await?; + Self::upsert_song_artist(database, song_id, &song_artist_ids).await?; + if self.compilation() { + // If the song has compilation, all artists in the song artists will be added to song + // album artists with compilation set to true. + Self::upsert_song_album_artist(database, song_id, &song_artist_ids, true).await?; + } + + // If there isn't any album artist, + // we assume that they are the same as artists. + let album_artist_ids = if self.album.is_empty() { + song_artist_ids + } else { + Artist::upserts(database, &self.album, prefixes).await? + }; + Self::upsert_song_album_artist(database, song_id, &album_artist_ids, false).await?; + + Ok(()) + } + + pub async fn cleanup_one( + database: &Database, + started_at: time::OffsetDateTime, + song_id: Uuid, + ) -> Result<(), Error> { + // Delete all artists of a song which haven't been refreshed since timestamp. + diesel::delete(songs_artists::table) + .filter(songs_artists::song_id.eq(song_id)) + .filter(songs_artists::upserted_at.lt(started_at)) + .execute(&mut database.get().await?) + .await?; + + // Delete all album artists of a song which haven't been refreshed since timestamp. + diesel::delete(songs_album_artists::table) + .filter(songs_album_artists::song_id.eq(song_id)) + .filter(songs_album_artists::upserted_at.lt(started_at)) + .execute(&mut database.get().await?) + .await?; + + Ok(()) + } + + pub async fn cleanup(database: &Database) -> Result<(), Error> { + // Delete all artists which does not have any relation with an album + // (via songs_album_artists) or a song (via songs_artists). + let alias_artists = diesel::alias!(artists as alias_artists); + diesel::delete(artists::table) + .filter( + artists::id.eq_any( + alias_artists + .filter(not(exists( + songs_album_artists::table.filter( + songs_album_artists::album_artist_id + .eq(alias_artists.field(artists::id)), + ), + ))) + .filter(not(exists(songs_artists::table.filter( + songs_artists::artist_id.eq(alias_artists.field(artists::id)), + )))) + .select(alias_artists.field(artists::id)), + ), + ) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use diesel::{QueryDsl, SelectableHelper}; + use itertools::Itertools; + + use super::*; + use crate::orm::songs; + use crate::test::Mock; + + impl + Sized> PartialEq for Artist<'_> { + fn eq(&self, other: &S) -> bool { + self.name == other.as_ref() && self.mbz_id.is_none() + } + } + + impl PartialEq for Artists<'_> { + fn eq(&self, other: &Self) -> bool { + (self.song() == other.song()) + && (self.album() == other.album()) + && (self.compilation() == other.compilation()) + } + } + + impl<'a> From<&'a str> for Artist<'a> { + fn from(value: &'a str) -> Self { + Self { name: value.into(), mbz_id: None } + } + } + + impl From for Artist<'static> { + fn from(value: String) -> Self { + Self { name: value.into(), mbz_id: None } + } + } + + impl<'a> From<(&'a str, Uuid)> for Artist<'a> { + fn from(value: (&'a str, Uuid)) -> Self { + Self { name: value.0.into(), mbz_id: Some(value.1) } + } + } + + impl Artist<'_> { + pub async fn upsert_mock(&self, mock: &Mock) -> Uuid { + self.upsert(mock.database(), &mock.config.index.ignore_prefixes).await.unwrap() + } + } + + impl Artist<'static> { + pub async fn query(mock: &Mock, id: Uuid) -> Self { + artists::table + .filter(artists::id.eq(id)) + .select(artists::Data::as_select()) + .get_result(&mut mock.get().await) + .await + .unwrap() + .into() + } + + async fn query_ids(mock: &Mock, ids: &[Uuid], sorted: bool) -> Vec { + let artists: Vec<_> = stream::iter(ids) + .copied() + .then(async |id| Self::query(mock, id).await) + .collect() + .await; + if sorted { artists.into_iter().sorted().collect() } else { artists } + } + + async fn query_song_artists(mock: &Mock, song_id: Uuid) -> (Vec, Vec) { + let ids: Vec = songs_artists::table + .filter(songs_artists::song_id.eq(song_id)) + .select(songs_artists::artist_id) + .order_by(songs_artists::upserted_at) + .get_results(&mut mock.get().await) + .await + .unwrap(); + let artists = Self::query_ids(mock, &ids, false).await; + (ids, artists) + } + + async fn query_song_album_artists( + mock: &Mock, + song_id: Uuid, + artist_ids: &[Uuid], + ) -> (Vec, bool) { + let ids_compilations = songs_album_artists::table + .filter(songs_album_artists::song_id.eq(song_id)) + .select((songs_album_artists::album_artist_id, songs_album_artists::compilation)) + .order_by(songs_album_artists::upserted_at) + .get_results::<(Uuid, bool)>(&mut mock.get().await) + .await + .unwrap(); + let artists: Vec<_> = stream::iter(&ids_compilations) + .copied() + .filter_map(|(id, compilation)| { + if compilation { + assert!( + artist_ids.contains(&id), + "Stale compilation album artist has not been removed yet" + ); + None + } else { + Some(id) + } + }) + .then(async |id| Self::query(mock, id).await) + .collect() + .await; + // If there is any compliation, it will be filtered out and make the size of two vectors + // not equal. On the other hand, two same size vectors can mean either there + // isn't any compilation or the song artists are the same as the album + // artists or there isn't any album artist (which then be filled with song + // artists). + let compilation = ids_compilations.len() != artists.len(); + (artists, compilation) + } + + pub async fn query_album(mock: &Mock, album_id: Uuid) -> Vec { + let ids = songs_album_artists::table + .inner_join(songs::table) + .select(songs_album_artists::album_artist_id) + .filter(songs::album_id.eq(album_id)) + .get_results(&mut mock.get().await) + .await + .unwrap(); + Self::query_ids(mock, &ids, true).await + } + + pub async fn queries(mock: &Mock) -> Vec { + let ids = artists::table + .select(artists::id) + .get_results(&mut mock.get().await) + .await + .unwrap(); + Self::query_ids(mock, &ids, true).await + } + } + + impl Artists<'static> { + pub async fn query(mock: &Mock, song_id: Uuid) -> Self { + let (artist_ids, song) = Artist::query_song_artists(mock, song_id).await; + let (album, compilation) = + Artist::query_song_album_artists(mock, song_id, &artist_ids).await; + Self::new(song, album, compilation).unwrap() + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[case("The One", &["The ", "A "], 'O')] + #[case("The 1", &["The ", "A "], '#')] + #[case("The one", &["The ", "A "], 'O')] + #[case("狼", &["The ", "A "], '狼')] + #[case("é", &["The ", "A "], 'E')] + #[case("ド", &["The ", "A "], 'ト')] + #[case("a", &["The ", "A "], 'A')] + #[case("%", &["The ", "A "], '*')] + fn test_index(#[case] name: &str, #[case] prefixes: &[&str], #[case] index: char) { + assert_eq!(Artist::from(name).index(prefixes).unwrap(), index); + } + + #[rstest] + #[case(&["Song"], &["Album"], true, true)] + #[case(&["Song"], &["Album"], false, false)] + #[case(&["Song"], &[], true, false)] + #[case(&["Song"], &[], false, false)] + #[case(&["Song"], &["Song"], true, false)] + #[case(&["Song"], &["Song"], false, false)] + #[case(&["Song"], &["Song", "Album"], true, false)] + #[case(&["Song"], &["Song", "Album"], false, false)] + #[case(&["Song1", "Song2"], &["Song1", "Song2", "Album"], true, false)] + #[case(&["Song1", "Song2"], &["Song1", "Song2", "Album"], false, false)] + #[case(&["Song1", "Song2"], &["Song2", "Album", "Song1"], true, false)] + #[case(&["Song1", "Song2"], &["Song2", "Album", "Song1"], false, false)] + fn test_compilation( + #[case] song: &[&str], + #[case] album: &[&str], + #[case] compilation: bool, + #[case] result: bool, + ) { + assert_eq!( + Artists::new( + song.iter().copied().map(Artist::from), + album.iter().copied().map(Artist::from), + compilation + ) + .unwrap() + .compilation(), + result + ); + } + + #[rstest] + #[tokio::test] + async fn test_artist_upsert_roundtrip( + #[future(awt)] mock: Mock, + #[values(true, false)] mbz_id: bool, + #[values(true, false)] update_artist: bool, + ) { + let mbz_id = if mbz_id { Some(Faker.fake()) } else { None }; + let artist = Artist { mbz_id, ..Faker.fake() }; + let id = artist.upsert_mock(&mock).await; + let database_artist = Artist::query(&mock, id).await; + assert_eq!(database_artist, artist); + + if update_artist { + let update_artist = Artist { mbz_id, ..Faker.fake() }; + let update_id = update_artist.upsert_mock(&mock).await; + let database_update_artist = Artist::query(&mock, id).await; + if mbz_id.is_some() { + assert_eq!(id, update_id); + assert_eq!(database_update_artist, update_artist); + } else { + // This will always insert a new row to the database + // since there is nothing to identify an old artist. + assert_ne!(id, update_id); + } + } + } + + #[rstest] + #[tokio::test] + async fn test_artist_upsert_no_mbz_id(#[future(awt)] mock: Mock) { + // We want to make sure that insert the same artist with no mbz_id + // twice does not result in any error. + let artist = Artist { mbz_id: None, ..Faker.fake() }; + let id = artist.upsert_mock(&mock).await; + let update_id = artist.upsert_mock(&mock).await; + assert_eq!(update_id, id); + } + + #[rstest] + #[tokio::test] + async fn test_artists_upsert( + #[future(awt)] mock: Mock, + #[values(true, false)] compilation: bool, + #[values(true, false)] update_artists: bool, + ) { + let database = mock.database(); + let prefixes = &mock.config.index.ignore_prefixes; + + let information: audio::Information = Faker.fake(); + let album_id = information.metadata.album.upsert_mock(&mock, 0).await; + let song_id = information + .upsert_song(database, album_id.into(), Faker.fake::(), None) + .await + .unwrap(); + + let artists = Artists { compilation, ..Faker.fake() }; + artists.upsert(database, prefixes, song_id).await.unwrap(); + let database_artists = Artists::query(&mock, song_id).await; + assert_eq!(database_artists, artists); + + if update_artists { + let timestamp = crate::time::now().await; + + let update_artists = Artists { compilation, ..Faker.fake() }; + update_artists.upsert(database, prefixes, song_id).await.unwrap(); + Artists::cleanup_one(database, timestamp, song_id).await.unwrap(); + let database_update_artists = Artists::query(&mock, song_id).await; + assert_eq!(database_update_artists, update_artists); + } + } + + mod cleanup { + use super::*; + + #[rstest] + #[tokio::test] + async fn test_artist_all(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song(5).call().await; + assert!(!Artist::queries(&mock).await.is_empty()); + + diesel::delete(songs_artists::table).execute(&mut mock.get().await).await.unwrap(); + diesel::delete(songs_album_artists::table) + .execute(&mut mock.get().await) + .await + .unwrap(); + + Artists::cleanup(mock.database()).await.unwrap(); + assert!(Artist::queries(&mock).await.is_empty()); + } + + #[rstest] + #[case(1, 0)] + #[case(1, 1)] + #[case(5, 3)] + #[case(5, 5)] + #[tokio::test] + async fn test_artist_song( + #[future(awt)] mock: Mock, + #[case] n_song: usize, + #[case] n_subset: usize, + #[values(true, false)] compilation: bool, + ) { + let mut music_folder = mock.music_folder(0).await; + let artist: Artist = Faker.fake(); + music_folder + .add_audio_artist( + [artist.clone(), Faker.fake()], + [Faker.fake()], + compilation, + n_song, + ) + .await; + let song_ids: Vec<_> = music_folder.database.keys().collect(); + assert!(Artist::queries(&mock).await.contains(&artist)); + + diesel::delete(songs_artists::table) + .filter(songs_artists::song_id.eq_any(&song_ids[0..n_subset])) + .execute(&mut mock.get().await) + .await + .unwrap(); + if compilation { + diesel::delete(songs_album_artists::table) + .filter(songs_album_artists::song_id.eq_any(&song_ids[0..n_subset])) + .execute(&mut mock.get().await) + .await + .unwrap(); + } + Artists::cleanup(mock.database()).await.unwrap(); + assert_eq!(Artist::queries(&mock).await.contains(&artist), n_subset < n_song); + } + + #[rstest] + #[case(1, 0)] + #[case(1, 1)] + #[case(5, 3)] + #[case(5, 5)] + #[tokio::test] + async fn test_artist_album( + #[future(awt)] mock: Mock, + #[case] n_album: usize, + #[case] n_subset: usize, + ) { + let artist: Artist = Faker.fake(); + let album_song_ids: Vec<(Uuid, Vec<_>)> = stream::iter(0..n_album) + .then(async |_| { + let mut music_folder = mock.music_folder(0).await; + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + music_folder + .add_audio_artist( + [Faker.fake()], + [artist.clone(), Faker.fake()], + false, + (1..3).fake(), + ) + .await; + let song_ids = music_folder.database.keys().copied().collect(); + (album_id, song_ids) + }) + .collect() + .await; + assert!(Artist::queries(&mock).await.contains(&artist)); + + diesel::delete(songs_album_artists::table) + .filter( + songs_album_artists::song_id.eq_any( + album_song_ids[0..n_subset] + .iter() + .flat_map(|(_, song_ids)| song_ids.clone()) + .collect::>(), + ), + ) + .execute(&mut mock.get().await) + .await + .unwrap(); + Artists::cleanup(mock.database()).await.unwrap(); + assert_eq!(Artist::queries(&mock).await.contains(&artist), n_subset < n_album); + } + } +} diff --git a/nghe-backend/src/file/audio/date.rs b/nghe-backend/src/file/audio/date.rs new file mode 100644 index 000000000..9fed890ee --- /dev/null +++ b/nghe-backend/src/file/audio/date.rs @@ -0,0 +1,173 @@ +use std::num::NonZeroU8; +use std::str::FromStr; + +use o2o::o2o; +use time::macros::format_description; +use time::Month; + +use crate::orm::{albums, songs}; +use crate::Error; + +type FormatDescription<'a> = &'a [time::format_description::BorrowedFormatItem<'a>]; + +const YMD_MINUS_FORMAT: FormatDescription = format_description!("[year]-[month]-[day]"); +const YM_MINUS_FORMAT: FormatDescription = format_description!("[year]-[month]"); +const YMD_SLASH_FORMAT: FormatDescription = format_description!("[year]/[month]/[day]"); +const YM_SLASH_FORMAT: FormatDescription = format_description!("[year]/[month]"); +const YMD_DOT_FORMAT: FormatDescription = format_description!("[year].[month].[day]"); +const YM_DOT_FORMAT: FormatDescription = format_description!("[year].[month]"); +const Y_FORMAT: FormatDescription = format_description!("[year]"); + +#[derive(Debug, Default, Clone, Copy, o2o)] +#[try_map_owned(songs::date::Date, Error)] +#[try_map_owned(songs::date::Release, Error)] +#[try_map_owned(songs::date::OriginalRelease, Error)] +#[try_map_owned(albums::date::Date, Error)] +#[try_map_owned(albums::date::Release, Error)] +#[try_map_owned(albums::date::OriginalRelease, Error)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct Date { + #[from(~.map(i32::from))] + #[into(~.map(i32::try_into).transpose()?)] + pub year: Option, + #[from(~.map(u8::try_from).transpose()?.map(Month::try_from).transpose()?)] + #[into(~.map(|month| (month as u8).into()))] + pub month: Option, + #[from(~.map(u8::try_from).transpose()?.map(NonZeroU8::try_from).transpose()?)] + #[into(~.map(NonZeroU8::get).map(u8::into))] + pub day: Option, +} + +impl Date { + pub fn is_some(&self) -> bool { + self.year.is_some() + } +} + +impl FromStr for Date { + type Err = Error; + + fn from_str(s: &str) -> Result { + let mut parsed = time::parsing::Parsed::new(); + let input = s.as_bytes(); + if !s.is_empty() + && parsed.parse_items(input, YMD_MINUS_FORMAT).is_err() + && parsed.parse_items(input, YMD_SLASH_FORMAT).is_err() + && parsed.parse_items(input, YMD_DOT_FORMAT).is_err() + && parsed.parse_items(input, YM_MINUS_FORMAT).is_err() + && parsed.parse_items(input, YM_SLASH_FORMAT).is_err() + && parsed.parse_items(input, YM_DOT_FORMAT).is_err() + { + // Don't aggresively parse everything as year + let result = parsed.parse_items(input, Y_FORMAT); + if result.is_err() || result.is_ok_and(|remain| !remain.is_empty()) { + return Err(Error::MediaDateFormat(s.to_owned())); + } + } + + Ok(Self { year: parsed.year(), month: parsed.month(), day: parsed.day() }) + } +} + +#[cfg(test)] +mod test { + use std::fmt::{Display, Formatter}; + + use fake::{Dummy, Fake, Faker}; + + use super::*; + + impl Display for Date { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(year) = self.year { + let mut result = format!("{year:04}"); + if let Some(month) = self.month { + let month = month as u8; + result += &format!("-{month:02}"); + if let Some(day) = self.day { + result += &format!("-{day:02}"); + } + } + write!(f, "{result}") + } else { + Err(std::fmt::Error) + } + } + } + + impl Dummy for Date { + fn dummy_with_rng(config: &Faker, rng: &mut R) -> Self { + let date: time::Date = config.fake_with_rng(rng); + + let year = + if config.fake_with_rng(rng) { Some(date.year().clamp(0, 9999)) } else { None }; + let month = + if year.is_some() && config.fake_with_rng(rng) { Some(date.month()) } else { None }; + let day = if month.is_some() && config.fake_with_rng(rng) { + Some(date.day().try_into().unwrap()) + } else { + None + }; + + Self { year, month, day } + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case("2000-12-01", Some(2000), Some(Month::December), Some(1))] + #[case("2000/12/01", Some(2000), Some(Month::December), Some(1))] + #[case("2000.12.01", Some(2000), Some(Month::December), Some(1))] + #[case("2000-12-01-still-ok", Some(2000), Some(Month::December), Some(1))] + #[case("2000.12.01/still/ok", Some(2000), Some(Month::December), Some(1))] + #[case("2000-12-01T02:29:01Z", Some(2000), Some(Month::December), Some(1))] + #[case("2000-12", Some(2000), Some(Month::December), None)] + #[case("2000/12", Some(2000), Some(Month::December), None)] + #[case("2000.12", Some(2000), Some(Month::December), None)] + #[case("2000", Some(2000), None, None)] + #[case("", None, None, None)] + fn test_parse_success( + #[case] input: &'static str, + #[case] year: Option, + #[case] month: Option, + #[case] day: Option, + ) { + let date: Date = input.parse().unwrap(); + assert_eq!(date.year, year); + assert_eq!(date.month, month); + assert_eq!(date.day.map(NonZeroU8::get), day); + } + + #[rstest] + #[case("2000-31")] + #[case("20-12-01")] + #[case("invalid")] + #[case("12-01")] + #[case("31")] + fn test_parse_error(#[case] input: &'static str) { + assert!(input.parse::().is_err()); + } + + #[rstest] + #[case(Some(2000), Some(Month::December), Some(31), "2000-12-31")] + #[case(Some(2000), Some(Month::December), None, "2000-12")] + #[case(Some(2000), None, Some(31), "2000")] + #[case(Some(2000), None, None, "2000")] + fn test_display( + #[case] year: Option, + #[case] month: Option, + #[case] day: Option, + #[case] str: &str, + ) { + assert_eq!( + Date { year, month, day: day.map(u8::try_into).transpose().unwrap() }.to_string(), + str + ); + } +} diff --git a/nghe-backend/src/file/audio/extract/flac.rs b/nghe-backend/src/file/audio/extract/flac.rs new file mode 100644 index 000000000..755e9f98f --- /dev/null +++ b/nghe-backend/src/file/audio/extract/flac.rs @@ -0,0 +1,66 @@ +use lofty::file::AudioFile; +use lofty::flac::FlacFile; +use lofty::ogg::OggPictureStorage as _; + +use super::{Metadata, Property}; +use crate::file::audio::{self, Album, Artists, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; +use crate::{config, Error}; + +impl<'a> Metadata<'a> for FlacFile { + fn song(&'a self, config: &'a config::Parsing) -> Result, Error> { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.song(config) + } + + fn album(&'a self, config: &'a config::Parsing) -> Result, Error> { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.album(config) + } + + fn artists(&'a self, config: &'a config::Parsing) -> Result, Error> { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.artists(config) + } + + fn track_disc(&'a self, config: &'a config::Parsing) -> Result { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.track_disc(config) + } + + fn languages(&'a self, config: &'a config::Parsing) -> Result, Error> { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.languages(config) + } + + fn genres(&'a self, config: &'a config::Parsing) -> Result, Error> { + self.vorbis_comments().ok_or(Error::MediaFlacMissingVorbisComments)?.genres(config) + } + + fn picture(&'a self) -> Result>, Error> { + let mut iter = self.pictures().iter(); + if cfg!(test) { + iter.find_map(|(picture, _)| { + if picture + .description() + .is_some_and(|description| description == Picture::TEST_DESCRIPTION) + { + Some(picture.try_into()) + } else { + None + } + }) + .transpose() + } else { + iter.next().map(|(picture, _)| picture.try_into()).transpose() + } + } +} + +impl Property for FlacFile { + fn property(&self) -> Result { + let properties = self.properties(); + Ok(audio::Property { + duration: properties.duration().as_secs_f32(), + bitrate: properties.audio_bitrate(), + bit_depth: Some(properties.bit_depth()), + sample_rate: properties.sample_rate(), + channel_count: properties.channels(), + }) + } +} diff --git a/nghe-backend/src/file/audio/extract/mod.rs b/nghe-backend/src/file/audio/extract/mod.rs new file mode 100644 index 000000000..193071709 --- /dev/null +++ b/nghe-backend/src/file/audio/extract/mod.rs @@ -0,0 +1,88 @@ +mod flac; +mod tag; + +use isolang::Language; + +use super::{Album, Artists, File, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; +use crate::{config, Error}; + +pub trait Metadata<'a> { + fn song(&'a self, config: &'a config::Parsing) -> Result, Error>; + fn album(&'a self, config: &'a config::Parsing) -> Result, Error>; + fn artists(&'a self, config: &'a config::Parsing) -> Result, Error>; + fn track_disc(&'a self, config: &'a config::Parsing) -> Result; + fn languages(&'a self, config: &'a config::Parsing) -> Result, Error>; + fn genres(&'a self, config: &'a config::Parsing) -> Result, Error>; + fn picture(&'a self) -> Result>, Error>; + + fn metadata(&'a self, config: &'a config::Parsing) -> Result, Error> { + Ok(super::Metadata { + song: super::Song { + main: self.song(config)?, + track_disc: self.track_disc(config)?, + languages: self.languages(config)?, + }, + album: self.album(config)?, + artists: self.artists(config)?, + genres: self.genres(config)?, + picture: self.picture()?, + }) + } +} + +pub trait Property { + fn property(&self) -> Result; +} + +impl<'a> Metadata<'a> for File { + fn song(&'a self, config: &'a config::Parsing) -> Result, Error> { + match self { + File::Flac { audio, .. } => audio.song(config), + } + } + + fn album(&'a self, config: &'a config::Parsing) -> Result, Error> { + match self { + File::Flac { audio, .. } => audio.album(config), + } + } + + fn artists(&'a self, config: &'a config::Parsing) -> Result, Error> { + match self { + File::Flac { audio, .. } => audio.artists(config), + } + } + + fn track_disc(&'a self, config: &'a config::Parsing) -> Result { + match self { + File::Flac { audio, .. } => audio.track_disc(config), + } + } + + fn languages(&'a self, config: &'a config::Parsing) -> Result, Error> { + match self { + File::Flac { audio, .. } => audio.languages(config), + } + } + + fn genres(&'a self, config: &'a config::Parsing) -> Result, Error> { + match self { + File::Flac { audio, .. } => audio.genres(config), + } + } + + fn picture(&'a self) -> Result>, Error> { + match self { + File::Flac { audio, .. } => audio.picture(), + } + } +} + +impl Property for File { + fn property(&self) -> Result { + match self { + File::Flac { audio, .. } => audio.property(), + } + } +} diff --git a/nghe-backend/src/file/audio/extract/tag/mod.rs b/nghe-backend/src/file/audio/extract/tag/mod.rs new file mode 100644 index 000000000..f425370c6 --- /dev/null +++ b/nghe-backend/src/file/audio/extract/tag/mod.rs @@ -0,0 +1 @@ +mod vorbis_comments; diff --git a/nghe-backend/src/file/audio/extract/tag/vorbis_comments.rs b/nghe-backend/src/file/audio/extract/tag/vorbis_comments.rs new file mode 100644 index 000000000..4c12001af --- /dev/null +++ b/nghe-backend/src/file/audio/extract/tag/vorbis_comments.rs @@ -0,0 +1,108 @@ +use std::str::FromStr; + +use color_eyre::eyre::OptionExt; +use indexmap::IndexSet; +use isolang::Language; +use itertools::Itertools; +use lofty::ogg::{OggPictureStorage, VorbisComments}; +use uuid::Uuid; + +use crate::file::audio::{extract, Album, Artist, Artists, Date, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; +use crate::{config, Error}; + +impl Date { + fn extract_vorbis_comments(tag: &VorbisComments, key: Option<&str>) -> Result { + if let Some(key) = key { + tag.get(key).map(Date::from_str).transpose().map(Option::unwrap_or_default) + } else { + Ok(Self::default()) + } + } +} + +impl<'a> NameDateMbz<'a> { + fn extract_vorbis_comments( + tag: &'a VorbisComments, + config: &'a config::parsing::vorbis_comments::Common, + ) -> Result { + Ok(Self { + name: tag.get(&config.name).ok_or_eyre("Could not extract name")?.into(), + date: Date::extract_vorbis_comments(tag, config.date.as_deref())?, + release_date: Date::extract_vorbis_comments(tag, config.release_date.as_deref())?, + original_release_date: Date::extract_vorbis_comments( + tag, + config.original_release_date.as_deref(), + )?, + mbz_id: tag.get(&config.mbz_id).map(Uuid::from_str).transpose()?, + }) + } +} + +impl<'a> Artist<'a> { + fn extract_vorbis_comments( + tag: &'a VorbisComments, + config: &'a config::parsing::vorbis_comments::Artist, + ) -> Result, Error> { + let names = tag.get_all(&config.name); + let mbz_ids = tag.get_all(&config.mbz_id).map(Uuid::from_str); + let artists = names + .zip_longest(mbz_ids) + .map(|iter| match iter { + itertools::EitherOrBoth::Both(name, mbz_id) => Ok(Self { + name: name.into(), + mbz_id: mbz_id + .map(|mbz_id| if mbz_id.is_nil() { None } else { Some(mbz_id) })?, + }), + itertools::EitherOrBoth::Left(name) => Ok(Self { name: name.into(), mbz_id: None }), + itertools::EitherOrBoth::Right(_) => Err(Error::MediaArtistMbzIdMoreThanArtistName), + }) + .try_collect()?; + Ok(artists) + } +} + +impl<'a> extract::Metadata<'a> for VorbisComments { + fn song(&'a self, config: &'a config::Parsing) -> Result, Error> { + NameDateMbz::extract_vorbis_comments(self, &config.vorbis_comments.song) + } + + fn album(&'a self, config: &'a config::Parsing) -> Result, Error> { + Album::extract_vorbis_comments(self, &config.vorbis_comments.album) + } + + fn artists(&'a self, config: &'a config::Parsing) -> Result, Error> { + Artists::new( + Artist::extract_vorbis_comments(self, &config.vorbis_comments.artists.song)?, + Artist::extract_vorbis_comments(self, &config.vorbis_comments.artists.album)?, + self.get(&config.vorbis_comments.compilation).is_some_and(|s| !s.is_empty()), + ) + } + + fn track_disc(&'a self, config: &'a config::Parsing) -> Result { + let config::parsing::vorbis_comments::TrackDisc { + track_number, + track_total, + disc_number, + disc_total, + } = &config.vorbis_comments.track_disc; + TrackDisc::parse( + self.get(track_number), + self.get(track_total), + self.get(disc_number), + self.get(disc_total), + ) + } + + fn languages(&'a self, config: &'a config::Parsing) -> Result, Error> { + Ok(self.get_all(&config.vorbis_comments.languages).map(Language::from_str).try_collect()?) + } + + fn genres(&'a self, config: &'a config::Parsing) -> Result, Error> { + Ok(self.get_all(&config.vorbis_comments.genres).collect()) + } + + fn picture(&'a self) -> Result>, Error> { + self.pictures().iter().next().map(|(picture, _)| picture.try_into()).transpose() + } +} diff --git a/nghe-backend/src/file/audio/genre.rs b/nghe-backend/src/file/audio/genre.rs new file mode 100644 index 000000000..e9812e890 --- /dev/null +++ b/nghe-backend/src/file/audio/genre.rs @@ -0,0 +1,209 @@ +use std::borrow::Cow; + +use diesel::dsl::{exists, not}; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +#[cfg(test)] +use fake::{Dummy, Fake, Faker}; +use o2o::o2o; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{genres, songs_genres}; +use crate::Error; + +#[derive(Debug, o2o)] +#[from_owned(genres::Data<'a>)] +#[ref_into(genres::Data<'a>)] +#[cfg_attr(test, derive(PartialEq, Eq, Dummy, Clone))] +pub struct Genre<'a> { + #[ref_into(~.as_str().into())] + #[cfg_attr(test, dummy(expr = "Faker.fake::().into()"))] + pub value: Cow<'a, str>, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq, Eq, Dummy, Clone))] +pub struct Genres<'a> { + #[cfg_attr(test, dummy(expr = "fake::vec![Genre<'static>; 0..=2]"))] + pub value: Vec>, +} + +impl<'a, S: Into>> From for Genre<'a> { + fn from(genre: S) -> Self { + Self { value: genre.into() } + } +} + +impl<'a, S: Into>> FromIterator for Genres<'a> { + fn from_iter>(iter: T) -> Self { + Self { value: iter.into_iter().map(Genre::from).collect() } + } +} + +impl<'a, 'b> From<&'a Genres<'b>> for Vec> +where + 'a: 'b, +{ + fn from(value: &'a Genres<'b>) -> Self { + value.value.iter().map(<&Genre>::into).collect() + } +} + +impl Genres<'_> { + pub async fn upsert(&self, database: &Database) -> Result, Error> { + diesel::insert_into(genres::table) + .values::>>(self.into()) + .on_conflict(genres::value) + .do_update() + .set(genres::upserted_at.eq(time::OffsetDateTime::now_utc())) + .returning(genres::id) + .get_results(&mut database.get().await?) + .await + .map_err(Error::from) + } + + pub async fn upsert_song( + database: &Database, + song_id: Uuid, + genre_ids: &[Uuid], + ) -> Result<(), Error> { + diesel::insert_into(songs_genres::table) + .values::>( + genre_ids + .iter() + .copied() + .map(|genre_id| songs_genres::Data { song_id, genre_id }) + .collect(), + ) + .on_conflict((songs_genres::song_id, songs_genres::genre_id)) + .do_update() + .set(songs_genres::upserted_at.eq(time::OffsetDateTime::now_utc())) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + + pub async fn cleanup_one( + database: &Database, + started_at: time::OffsetDateTime, + song_id: Uuid, + ) -> Result<(), Error> { + // Delete all genres of a song which haven't been refreshed since timestamp. + diesel::delete(songs_genres::table) + .filter(songs_genres::song_id.eq(song_id)) + .filter(songs_genres::upserted_at.lt(started_at)) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + + pub async fn cleanup(database: &Database) -> Result<(), Error> { + // Delete all genres which do not have any song associated. + let alias_genres = diesel::alias!(genres as alias_genres); + diesel::delete(genres::table) + .filter( + genres::id.eq_any( + alias_genres + .filter(not(exists( + songs_genres::table + .filter(songs_genres::genre_id.eq(alias_genres.field(genres::id))), + ))) + .select(alias_genres.field(genres::id)), + ), + ) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use diesel::SelectableHelper; + use futures_lite::{stream, StreamExt}; + + use super::*; + use crate::orm::songs; + use crate::test::Mock; + + impl Genre<'static> { + pub async fn query(mock: &Mock, id: Uuid) -> Self { + genres::table + .filter(genres::id.eq(id)) + .select(genres::Data::as_select()) + .get_result(&mut mock.get().await) + .await + .unwrap() + .into() + } + + pub async fn queries(mock: &Mock) -> Vec { + let ids = genres::table + .select(genres::id) + .order_by(genres::value) + .get_results(&mut mock.get().await) + .await + .unwrap(); + stream::iter(ids).then(async |id| Self::query(mock, id).await).collect().await + } + } + + impl Genres<'static> { + pub async fn query(mock: &Mock, song_id: Uuid) -> Self { + songs_genres::table + .inner_join(songs::table) + .inner_join(genres::table) + .filter(songs::id.eq(song_id)) + .select(genres::value) + .get_results::(&mut mock.get().await) + .await + .unwrap() + .into_iter() + .collect() + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + mod cleanup { + use super::*; + + #[rstest] + #[case(1, 0)] + #[case(1, 1)] + #[case(5, 3)] + #[case(5, 5)] + #[tokio::test] + async fn test_genre( + #[future(awt)] mock: Mock, + #[case] n_song: usize, + #[case] n_subset: usize, + ) { + let mut music_folder = mock.music_folder(0).await; + let genre: Genre = Faker.fake(); + music_folder + .add_audio() + .genres(Genres { value: vec![genre.clone()] }) + .n_song(n_song) + .call() + .await; + let song_ids: Vec<_> = music_folder.database.keys().collect(); + assert!(Genre::queries(&mock).await.contains(&genre)); + + diesel::delete(songs_genres::table) + .filter(songs_genres::song_id.eq_any(&song_ids[0..n_subset])) + .execute(&mut mock.get().await) + .await + .unwrap(); + Genres::cleanup(mock.database()).await.unwrap(); + assert_eq!(Genre::queries(&mock).await.contains(&genre), n_subset < n_song); + } + } +} diff --git a/nghe-backend/src/file/audio/information.rs b/nghe-backend/src/file/audio/information.rs new file mode 100644 index 000000000..c8eef580e --- /dev/null +++ b/nghe-backend/src/file/audio/information.rs @@ -0,0 +1,150 @@ +use std::borrow::Cow; + +use diesel::ExpressionMethods; +use diesel_async::RunQueryDsl; +use o2o::o2o; +use typed_path::Utf8NativePath; +use uuid::Uuid; + +use super::{Album, Artists, Genres}; +use crate::database::Database; +use crate::orm::upsert::Upsert as _; +use crate::orm::{albums, songs}; +use crate::scan::scanner; +use crate::{file, Error}; + +#[derive(Debug, o2o)] +#[ref_try_into(songs::Data<'a>, Error)] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy, Clone))] +pub struct Information<'a> { + #[ref_into(songs::Data<'a>| song, (&~.song).try_into()?)] + pub metadata: super::Metadata<'a>, + #[map(~.try_into()?)] + pub property: super::Property, + #[map(~.into())] + pub file: file::Property, +} + +impl Information<'_> { + pub async fn upsert_album( + &self, + database: &Database, + foreign: albums::Foreign, + ) -> Result { + self.metadata.album.upsert(database, foreign).await + } + + pub async fn upsert_artists( + &self, + database: &Database, + prefixes: &[impl AsRef], + song_id: Uuid, + ) -> Result<(), Error> { + self.metadata.artists.upsert(database, prefixes, song_id).await + } + + pub async fn upsert_genres(&self, database: &Database, song_id: Uuid) -> Result<(), Error> { + let genre_ids = self.metadata.genres.upsert(database).await?; + Genres::upsert_song(database, song_id, &genre_ids).await + } + + pub async fn upsert_cover_art( + &self, + database: &Database, + dir: Option<&impl AsRef>, + ) -> Result, Error> { + Ok( + if let Some(ref picture) = self.metadata.picture + && let Some(dir) = dir + { + Some(picture.upsert(database, dir).await?) + } else { + None + }, + ) + } + + pub async fn upsert_song( + &self, + database: &Database, + foreign: songs::Foreign, + relative_path: impl Into>, + id: impl Into>, + ) -> Result { + songs::Upsert { foreign, relative_path: relative_path.into(), data: self.try_into()? } + .upsert(database, id) + .await + } + + pub async fn upsert( + &self, + database: &Database, + config: &scanner::Config, + foreign: albums::Foreign, + relative_path: impl Into>, + song_id: impl Into>, + ) -> Result { + let album_id = self.upsert_album(database, foreign).await?; + let cover_art_id = self.upsert_cover_art(database, config.cover_art.dir.as_ref()).await?; + let foreign = songs::Foreign { album_id, cover_art_id }; + + let song_id = self.upsert_song(database, foreign, relative_path, song_id).await?; + self.upsert_artists(database, &config.index.ignore_prefixes, song_id).await?; + self.upsert_genres(database, song_id).await?; + Ok(song_id) + } + + pub async fn cleanup_one( + database: &Database, + started_at: time::OffsetDateTime, + song_id: Uuid, + ) -> Result<(), Error> { + Artists::cleanup_one(database, started_at, song_id).await?; + Genres::cleanup_one(database, started_at, song_id).await?; + Ok(()) + } + + pub async fn cleanup( + database: &Database, + started_at: time::OffsetDateTime, + ) -> Result<(), Error> { + diesel::delete(songs::table) + .filter(songs::scanned_at.lt(started_at)) + .execute(&mut database.get().await?) + .await?; + Album::cleanup(database).await?; + Artists::cleanup(database).await?; + Genres::cleanup(database).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use crate::test::{mock, Information, Mock}; + + #[rstest] + #[tokio::test] + async fn test_information_roundtrip( + #[future(awt)] mock: Mock, + #[values(true, false)] update_information: bool, + ) { + let information = Information::builder().build(); + let id = information.upsert_mock(&mock, 0, None).await; + let database_information = Information::query(&mock, id).await; + assert_eq!(database_information, information); + + if update_information { + let timestamp = crate::time::now().await; + + let update_information = Information::builder().build(); + let update_id = update_information.upsert_mock(&mock, 0, id).await; + super::Information::cleanup_one(mock.database(), timestamp, id).await.unwrap(); + let database_update_information = Information::query(&mock, id).await; + assert_eq!(update_id, id); + assert_eq!(database_update_information, update_information); + } + } +} diff --git a/nghe-backend/src/file/audio/metadata.rs b/nghe-backend/src/file/audio/metadata.rs new file mode 100644 index 000000000..b45e70932 --- /dev/null +++ b/nghe-backend/src/file/audio/metadata.rs @@ -0,0 +1,97 @@ +use std::str::FromStr; + +#[cfg(test)] +use fake::{Dummy, Fake}; +use isolang::Language; +#[cfg(test)] +use itertools::Itertools; +use o2o::o2o; + +use super::{artist, name_date_mbz, position, Genres}; +use crate::file::picture::Picture; +use crate::orm::songs; +use crate::Error; + +#[derive(Debug, o2o)] +#[try_map_owned(songs::Song<'a>, Error)] +#[ref_try_into(songs::Song<'a>, Error)] +#[cfg_attr(test, derive(PartialEq, Eq, Dummy, Clone))] +pub struct Song<'a> { + #[map_owned(~.try_into()?)] + #[ref_into((&~).try_into()?)] + pub main: name_date_mbz::NameDateMbz<'a>, + #[map(~.try_into()?)] + pub track_disc: position::TrackDisc, + #[from(~.into_iter().map( + |language|Language::from_str(language.ok_or_else( + || Error::LanguageFromDatabaseIsNull)?.as_ref() + ).map_err(Error::from) + ).try_collect()?)] + #[into(~.iter().map(|language| Some(language.to_639_3().into())).collect())] + #[cfg_attr( + test, + dummy(expr = "((0..=7915), \ + 0..=2).fake::>().into_iter().unique().\ + map(Language::from_usize).collect::>().unwrap()") + )] + pub languages: Vec, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq, Eq, Dummy, Clone))] +pub struct Metadata<'a> { + pub song: Song<'a>, + pub album: name_date_mbz::Album<'a>, + pub artists: artist::Artists<'a>, + pub genres: Genres<'a>, + pub picture: Option>, +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::{self, audio}; + use crate::test::{mock, Information, Mock}; + + #[rstest] + #[tokio::test] + async fn test_song_roundtrip( + #[future(awt)] mock: Mock, + #[values(true, false)] update_song: bool, + ) { + let song: audio::Information = Faker.fake(); + let album_id = song.metadata.album.upsert_mock(&mock, 0).await; + let id = song + .upsert_song(mock.database(), album_id.into(), Faker.fake::(), None) + .await + .unwrap(); + + let database_data = Information::query_data(&mock, id).await; + let database_song: Song = database_data.song.try_into().unwrap(); + let database_property: audio::Property = database_data.property.try_into().unwrap(); + let database_file: file::Property<_> = database_data.file.into(); + assert_eq!(database_song, song.metadata.song); + assert_eq!(database_property, song.property); + assert_eq!(database_file, song.file); + + if update_song { + let update_song: audio::Information = Faker.fake(); + let update_id = update_song + .upsert_song(mock.database(), album_id.into(), Faker.fake::(), id) + .await + .unwrap(); + + let update_database_data = Information::query_data(&mock, update_id).await; + let update_database_song: Song = update_database_data.song.try_into().unwrap(); + let update_database_property: audio::Property = + update_database_data.property.try_into().unwrap(); + let update_database_file: file::Property<_> = update_database_data.file.into(); + assert_eq!(update_database_song, update_song.metadata.song); + assert_eq!(update_database_property, update_song.property); + assert_eq!(update_database_file, update_song.file); + } + } +} diff --git a/nghe-backend/src/file/audio/mod.rs b/nghe-backend/src/file/audio/mod.rs new file mode 100644 index 000000000..89cd905fd --- /dev/null +++ b/nghe-backend/src/file/audio/mod.rs @@ -0,0 +1,228 @@ +mod artist; +mod date; +mod extract; +mod genre; +mod information; +mod metadata; +mod name_date_mbz; +pub mod position; +mod property; + +use std::io::Cursor; + +pub use artist::{Artist, Artists}; +pub use date::Date; +use diesel::sql_types::Text; +use diesel::{AsExpression, FromSqlRow}; +use extract::{Metadata as _, Property as _}; +pub use genre::Genres; +pub use information::Information; +use lofty::config::ParseOptions; +use lofty::file::AudioFile; +use lofty::flac::FlacFile; +pub use metadata::{Metadata, Song}; +pub use name_date_mbz::{Album, NameDateMbz}; +use nghe_api::common::format; +pub use position::TrackDisc; +pub use property::Property; +use strum::{EnumString, IntoStaticStr}; + +use crate::{config, Error}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + EnumString, + IntoStaticStr, + AsExpression, + FromSqlRow, +)] +#[diesel(sql_type = Text)] +#[strum(serialize_all = "snake_case")] +#[cfg_attr(test, derive(fake::Dummy, strum::AsRefStr))] +pub enum Format { + Flac, +} + +pub enum File { + Flac { audio: FlacFile, file: super::File }, +} + +impl format::Trait for Format { + fn mime(&self) -> &'static str { + match self { + Self::Flac => "audio/flac", + } + } + + fn extension(&self) -> &'static str { + self.into() + } +} + +impl super::File { + pub fn audio(self, parse_options: ParseOptions) -> Result { + let mut reader = Cursor::new(&self.data); + match self.property.format { + Format::Flac => Ok(File::Flac { + audio: FlacFile::read_from(&mut reader, parse_options)?, + file: self, + }), + } + } +} + +impl File { + pub fn file(&self) -> &super::File { + match self { + Self::Flac { file, .. } => file, + } + } + + pub fn extract<'a>(&'a self, config: &'a config::Parsing) -> Result, Error> { + Ok(Information { + metadata: self.metadata(config)?, + property: self.property()?, + file: self.file().property, + }) + } +} + +#[cfg(test)] +mod test { + use std::io::Cursor; + + use lofty::config::WriteOptions; + use lofty::ogg::VorbisComments; + + use super::*; + + impl File { + pub fn clear(&mut self) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.remove_id3v2(); + audio.set_vorbis_comments(VorbisComments::default()); + } + } + self + } + + pub fn save_to(&self, cursor: &mut Cursor>, write_options: WriteOptions) { + match self { + File::Flac { audio, .. } => { + audio.save_to(cursor, write_options).unwrap(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use isolang::Language; + use nghe_api::common::filesystem; + use position::Position; + use rstest::rstest; + use time::Month; + + use super::*; + use crate::file::File; + use crate::test::{assets, mock, Mock}; + + #[rstest] + fn test_media(#[values(Format::Flac)] format: Format) { + let file = File::new(format, std::fs::read(assets::path(format).as_str()).unwrap()) + .unwrap() + .audio(ParseOptions::default()) + .unwrap(); + + let config = config::Parsing::test(); + let media = file.extract(&config).unwrap(); + let metadata = media.metadata; + + let song = metadata.song; + let main = song.main; + assert_eq!(main.name, "Sample"); + assert_eq!(main.date, Date::default()); + assert_eq!(main.release_date, Date::default()); + assert_eq!(main.original_release_date, Date::default()); + assert_eq!(main.mbz_id, None); + + assert_eq!( + song.track_disc, + TrackDisc { + track: Position { number: Some(10), total: None }, + disc: Position { number: Some(5), total: Some(10) } + } + ); + + assert_eq!(song.languages, &[Language::Eng, Language::Vie]); + + let album = metadata.album; + assert_eq!(album.name, "Album"); + assert_eq!( + album.date, + Date { + year: Some(2000), + month: Some(Month::December), + day: Some(31.try_into().unwrap()) + } + ); + assert_eq!(album.release_date, Date::default()); + assert_eq!( + album.original_release_date, + Date { year: Some(3000), month: Some(Month::January), day: None } + ); + assert_eq!(album.mbz_id, None); + + let artists = metadata.artists; + let song = artists.song; + assert_eq!( + song.into_iter().collect::>(), + &[ + ("Artist1", uuid::uuid!("1ffedd2d-f63d-4dc2-9332-d3132e5134ac")).into(), + Artist::from("Artist2") + ] + ); + let album = artists.album; + assert_eq!(album.into_iter().collect::>(), &["Artist1", "Artist3"]); + assert!(artists.compilation); + + assert!(metadata.genres.value.is_empty()); + + assert_eq!(media.property, Property::default(format)); + } + + #[rstest] + #[tokio::test] + async fn test_roundtrip( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + #[values(Format::Flac)] format: Format, + ) { + mock.add_music_folder().ty(ty).call().await; + let mut music_folder = mock.music_folder(0).await; + let metadata: Metadata = Faker.fake(); + let roundtrip_file = music_folder + .add_audio_filesystem() + .path("test") + .format(format) + .metadata(metadata.clone()) + .call() + .await + .file("test".into(), format) + .await; + let roundtrip_audio = roundtrip_file.extract(&mock.config.parsing).unwrap(); + assert_eq!(roundtrip_audio.metadata, metadata); + assert_eq!(roundtrip_audio.property, Property::default(format)); + } +} diff --git a/nghe-backend/src/file/audio/name_date_mbz.rs b/nghe-backend/src/file/audio/name_date_mbz.rs new file mode 100644 index 000000000..7051623ac --- /dev/null +++ b/nghe-backend/src/file/audio/name_date_mbz.rs @@ -0,0 +1,224 @@ +use std::borrow::Cow; + +use diesel::dsl::{exists, not}; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +#[cfg(test)] +use fake::{Dummy, Fake, Faker}; +use o2o::o2o; +use uuid::Uuid; + +use super::date::Date; +use crate::database::Database; +use crate::orm::upsert::Insert as _; +use crate::orm::{albums, songs}; +use crate::Error; + +#[derive(Debug, o2o)] +#[try_map_owned(songs::name_date_mbz::NameDateMbz<'a>, Error)] +#[try_map_owned(albums::Data<'a>, Error)] +#[ref_try_into(songs::name_date_mbz::NameDateMbz<'a>, Error)] +#[ref_try_into(albums::Data<'a>, Error)] +#[cfg_attr(test, derive(PartialEq, Eq, Dummy, Clone, Default))] +pub struct NameDateMbz<'a> { + #[ref_into(~.as_str().into())] + #[cfg_attr(test, dummy(expr = "Faker.fake::().into()"))] + pub name: Cow<'a, str>, + #[map(~.try_into()?)] + pub date: Date, + #[map(~.try_into()?)] + pub release_date: Date, + #[map(~.try_into()?)] + pub original_release_date: Date, + pub mbz_id: Option, +} + +pub type Album<'a> = NameDateMbz<'a>; + +impl Album<'_> { + pub async fn upsert( + &self, + database: &Database, + foreign: albums::Foreign, + ) -> Result { + albums::Upsert { foreign, data: self.try_into()? }.insert(database).await + } + + pub async fn cleanup(database: &Database) -> Result<(), Error> { + // Delete all albums which do not have any song associated. + let alias_albums = diesel::alias!(albums as alias); + diesel::delete(albums::table) + .filter( + albums::id.eq_any( + alias_albums + .filter(not(exists( + songs::table.filter(songs::album_id.eq(alias_albums.field(albums::id))), + ))) + .select(alias_albums.field(albums::id)), + ), + ) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; + use diesel_async::RunQueryDsl; + use futures_lite::{stream, StreamExt}; + + use super::*; + use crate::test::Mock; + + impl<'a, S: Into>> From for Album<'a> { + fn from(value: S) -> Self { + Self { name: value.into(), ..Self::default() } + } + } + + impl Album<'_> { + pub async fn upsert_mock(&self, mock: &Mock, index: usize) -> Uuid { + self.upsert(mock.database(), mock.music_folder_id(index).await.into()).await.unwrap() + } + } + + impl Album<'static> { + pub async fn query_upsert(mock: &Mock, id: Uuid) -> albums::Upsert<'static> { + albums::table + .filter(albums::id.eq(id)) + .select(albums::Upsert::as_select()) + .get_result(&mut mock.get().await) + .await + .unwrap() + } + + pub async fn query(mock: &Mock, id: Uuid) -> Self { + Self::query_upsert(mock, id).await.data.try_into().unwrap() + } + + pub async fn queries(mock: &Mock) -> Vec { + let ids = albums::table + .select(albums::id) + .order_by(albums::name) + .get_results(&mut mock.get().await) + .await + .unwrap(); + stream::iter(ids).then(async |id| Self::query(mock, id).await).collect().await + } + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use itertools::Itertools; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_album_upsert_roundtrip( + #[future(awt)] mock: Mock, + #[values(true, false)] mbz_id: bool, + #[values(true, false)] update_album: bool, + ) { + let mbz_id = if mbz_id { Some(Faker.fake()) } else { None }; + let album = Album { mbz_id, ..Faker.fake() }; + let id = album.upsert_mock(&mock, 0).await; + let database_album = Album::query(&mock, id).await; + assert_eq!(database_album, album); + + if update_album { + let update_album = Album { mbz_id, ..Faker.fake() }; + let update_id = update_album.upsert_mock(&mock, 0).await; + let database_update_album = Album::query(&mock, id).await; + if mbz_id.is_some() { + assert_eq!(id, update_id); + assert_eq!(database_update_album, update_album); + } else { + // This will always insert a new row to the database + // since there is nothing to identify an old album. + assert_ne!(id, update_id); + } + } + } + + #[rstest] + #[tokio::test] + async fn test_album_upsert_no_mbz_id(#[future(awt)] mock: Mock) { + // We want to make sure that insert the same album with no mbz_id + // twice does not result in any error. + let album = Album { mbz_id: None, ..Faker.fake() }; + let id = album.upsert_mock(&mock, 0).await; + let update_id = album.upsert_mock(&mock, 0).await; + assert_eq!(update_id, id); + } + + #[rstest] + #[tokio::test] + async fn test_combine_album_artist( + #[future(awt)] mock: Mock, + #[values(true, false)] compilation: bool, + ) { + let mut music_folder = mock.music_folder(0).await; + let album: Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + let artists: Vec<_> = fake::vec![audio::Artist; 4].into_iter().sorted().collect(); + music_folder + .add_audio() + .album(album.clone()) + .artists(audio::Artists { + song: [artists[0].clone()].into(), + album: [artists[2].clone()].into(), + compilation, + }) + .call() + .await + .add_audio() + .album(album.clone()) + .artists(audio::Artists { + song: [artists[1].clone()].into(), + album: [artists[3].clone()].into(), + compilation, + }) + .call() + .await; + let range = if compilation { 0..4 } else { 2..4 }; + assert_eq!(artists[range], audio::Artist::query_album(&mock, album_id).await); + } + + mod cleanup { + use super::*; + + #[rstest] + #[case(1, 0)] + #[case(1, 1)] + #[case(5, 3)] + #[case(5, 5)] + #[tokio::test] + async fn test_album( + #[future(awt)] mock: Mock, + #[case] n_song: usize, + #[case] n_subset: usize, + ) { + let mut music_folder = mock.music_folder(0).await; + let album: Album = Faker.fake(); + music_folder.add_audio().album(album.clone()).n_song(n_song).call().await; + let song_ids: Vec<_> = music_folder.database.keys().collect(); + assert!(Album::queries(&mock).await.contains(&album)); + + diesel::delete(songs::table) + .filter(songs::id.eq_any(&song_ids[0..n_subset])) + .execute(&mut mock.get().await) + .await + .unwrap(); + Album::cleanup(mock.database()).await.unwrap(); + assert_eq!(Album::queries(&mock).await.contains(&album), n_subset < n_song); + } + } +} diff --git a/nghe-backend/src/file/audio/position.rs b/nghe-backend/src/file/audio/position.rs new file mode 100644 index 000000000..35014a447 --- /dev/null +++ b/nghe-backend/src/file/audio/position.rs @@ -0,0 +1,201 @@ +use o2o::o2o; + +use crate::orm::songs; +use crate::Error; + +#[derive(Debug, Default, Clone, Copy, o2o)] +#[try_map_owned(songs::position::Track, Error)] +#[try_map_owned(songs::position::Disc, Error)] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct Position { + #[from(~.map(u16::try_from).transpose()?)] + #[into(~.map(u16::into))] + pub number: Option, + #[from(~.map(u16::try_from).transpose()?)] + #[into(~.map(u16::into))] + pub total: Option, +} + +#[derive(Debug, Default, Clone, Copy, o2o)] +#[try_map_owned(songs::position::TrackDisc, Error)] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct TrackDisc { + #[map(~.try_into()?)] + pub track: Position, + #[map(~.try_into()?)] + pub disc: Position, +} + +impl Position { + fn parse(number_str: Option<&str>, total_str: Option<&str>) -> Option { + if let Some(number) = number_str { + // Prioritize parsing from number_str if there is a slash inside + if let Some((number, total)) = number.split_once('/') { + let number = Some(number.parse().ok()?); + let total = if total.is_empty() { + total_str.map(str::parse).transpose().ok()? + } else { + Some(total.parse().ok()?) + }; + Some(Self { number, total }) + } else { + let number = Some(number.parse().ok()?); + let total = total_str.map(str::parse).transpose().ok()?; + Some(Self { number, total }) + } + } else { + let total = total_str.map(str::parse).transpose().ok()?; + Some(Self { number: None, total }) + } + } +} + +impl TrackDisc { + pub fn parse( + track_number: Option<&str>, + track_total: Option<&str>, + disc_number: Option<&str>, + disc_total: Option<&str>, + ) -> Result { + if let Some(track) = Position::parse(track_number, track_total) + && let Some(disc) = Position::parse(disc_number, disc_total) + { + Ok(Self { track, disc }) + } else if let Some(track_disc) = Self::parse_vinyl_position(track_number) { + Ok(track_disc) + } else { + Err(Error::MediaPositionFormat { + track_number: track_number.map(str::to_owned), + track_total: track_total.map(str::to_owned), + disc_number: disc_number.map(str::to_owned), + disc_total: disc_total.map(str::to_owned), + }) + } + } + + // This position format is encountered when extracting metadata from some Vinyl records. + fn parse_vinyl_position(str: Option<&str>) -> Option { + if let Some(str) = str + && let Some(disc_letter) = str.chars().next() + && disc_letter.is_ascii_alphabetic() + { + // In ASCII, `A` is equal to 65. + let disc_number = (disc_letter.to_ascii_uppercase() as u8 - 64).into(); + let track_number = str[1..].parse().ok()?; + Some(Self { + track: Position { number: Some(track_number), total: None }, + disc: Position { number: Some(disc_number), total: None }, + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::too_many_arguments)] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case(None, None, None, None)] + #[case(Some("10"), None, Some(10), None)] + #[case(None, Some("20"), None, Some(20))] + #[case(Some("10"), Some("20"), Some(10), Some(20))] + #[case(Some("10/20"), None, Some(10), Some(20))] + #[case(Some("10/20"), Some("30"), Some(10), Some(20))] + #[case(Some("10/"), None, Some(10), None)] + #[case(Some("10/"), Some("30"), Some(10), Some(30))] + fn test_parse_position_success( + #[case] number_str: Option<&str>, + #[case] total_str: Option<&str>, + #[case] number: Option, + #[case] total: Option, + ) { + let position = Position::parse(number_str, total_str).unwrap(); + assert_eq!(position.number, number); + assert_eq!(position.total, total); + } + + #[rstest] + #[case(Some("/"), None)] + #[case(Some("-10/20"), None)] + #[case(Some("-10"), Some("-20"))] + #[case(None, Some("A"))] + #[case(None, Some("10/20"))] + fn test_parse_position_error( + #[case] number_str: Option<&str>, + #[case] total_str: Option<&str>, + ) { + assert!(Position::parse(number_str, total_str).is_none()); + } + + #[rstest] + #[case(Some("A1"), 1, 1)] + #[case(Some("E100"), 100, 5)] + #[case(Some("c1000"), 1000, 3)] + #[case(Some("Z0"), 0, 26)] + fn test_parse_vinyl_position_success( + #[case] str: Option<&str>, + #[case] track_number: u16, + #[case] disc_number: u16, + ) { + let track_disc = TrackDisc::parse_vinyl_position(str).unwrap(); + assert_eq!(track_disc.track.number, Some(track_number)); + assert!(track_disc.track.total.is_none()); + assert_eq!(track_disc.disc.number, Some(disc_number)); + assert!(track_disc.disc.total.is_none()); + } + + #[rstest] + #[case(None)] + #[case(Some("1A"))] + #[case(Some("A1B"))] + #[case(Some("1000"))] + fn test_parse_vinyl_position_error(#[case] str: Option<&str>) { + assert!(TrackDisc::parse_vinyl_position(str).is_none()); + } + + #[rstest] + #[case(None, None, None, None, None, None, None, None)] + #[case(Some("A2"), None, None, None, Some(2), None, Some(1), None)] + #[case(Some("1/"), None, None, Some("10"), Some(1), None, None, Some(10))] + #[case(Some("10"), Some("20"), Some("2/5"), None, Some(10), Some(20), Some(2), Some(5))] + fn test_parse_track_disc_success( + #[case] track_number_str: Option<&str>, + #[case] track_total_str: Option<&str>, + #[case] disc_number_str: Option<&str>, + #[case] disc_total_str: Option<&str>, + #[case] track_number: Option, + #[case] track_total: Option, + #[case] disc_number: Option, + #[case] disc_total: Option, + ) { + let track_disc = + TrackDisc::parse(track_number_str, track_total_str, disc_number_str, disc_total_str) + .unwrap(); + assert_eq!(track_disc.track.number, track_number); + assert_eq!(track_disc.track.total, track_total); + assert_eq!(track_disc.disc.number, disc_number); + assert_eq!(track_disc.disc.total, disc_total); + } + + #[rstest] + #[case(Some("1A"), None, None, None)] + #[case(Some("10"), Some("B"), None, None)] + #[case(Some("10"), None, Some("20/Z"), None)] + fn test_parse_track_disc_error( + #[case] track_number_str: Option<&str>, + #[case] track_total_str: Option<&str>, + #[case] disc_number_str: Option<&str>, + #[case] disc_total_str: Option<&str>, + ) { + assert!( + TrackDisc::parse(track_number_str, track_total_str, disc_number_str, disc_total_str) + .is_err() + ); + } +} diff --git a/nghe-backend/src/file/audio/property.rs b/nghe-backend/src/file/audio/property.rs new file mode 100644 index 000000000..23ab0b231 --- /dev/null +++ b/nghe-backend/src/file/audio/property.rs @@ -0,0 +1,34 @@ +#[derive(Debug, Clone, Copy)] +#[cfg_attr(test, derive(educe::Educe, fake::Dummy))] +#[cfg_attr(test, educe(PartialEq, Eq))] +pub struct Property { + #[cfg_attr(test, educe(PartialEq(ignore)))] + #[cfg_attr(test, dummy(faker = "100f32..300f32"))] + pub duration: f32, + #[cfg_attr(test, dummy(faker = "32000..640000"))] + pub bitrate: u32, + pub bit_depth: Option, + #[cfg_attr(test, dummy(faker = "10000..44000"))] + pub sample_rate: u32, + pub channel_count: u8, +} + +#[cfg(test)] +mod test { + use super::*; + use crate::file::audio; + + impl Property { + pub fn default(ty: audio::Format) -> Self { + match ty { + audio::Format::Flac => Self { + duration: 0f32, + bitrate: 585, + bit_depth: Some(24), + sample_rate: 32000, + channel_count: 2, + }, + } + } + } +} diff --git a/nghe-backend/src/file/mod.rs b/nghe-backend/src/file/mod.rs new file mode 100644 index 000000000..5fcb575c9 --- /dev/null +++ b/nghe-backend/src/file/mod.rs @@ -0,0 +1,127 @@ +pub mod audio; +pub mod picture; + +use std::time::Duration; + +use axum_extra::headers::{CacheControl, ETag}; +use nghe_api::common::format; +use typed_path::{Utf8NativePath, Utf8NativePathBuf}; +use xxhash_rust::xxh3::xxh3_64; + +use crate::http::binary::property; +use crate::http::header::ToETag; +use crate::Error; + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct Property { + pub hash: u64, + pub size: u32, + pub format: F, +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct PropertySize { + pub size: u64, + pub format: F, +} + +#[derive(Debug)] +pub struct File { + pub data: Vec, + pub property: Property, +} + +impl Property { + pub fn new(format: F, data: impl AsRef<[u8]>) -> Result { + let data = data.as_ref(); + let hash = xxh3_64(data); + let size = data.len().try_into()?; + Ok(Self { hash, size, format }) + } + + pub fn replace(&self, format: FN) -> Property { + Property { hash: self.hash, size: self.size, format } + } + + fn path_dir(&self, base: impl AsRef) -> Utf8NativePathBuf { + let hash = self.hash.to_le_bytes(); + + // Avoid putting too many files in a single directory + let first = faster_hex::hex_string(&hash[..1]); + let second = faster_hex::hex_string(&hash[1..]); + + base.as_ref().join(first).join(second).join(self.size.to_string()) + } + + pub fn path( + &self, + base: impl AsRef, + name: impl Into>, + ) -> Utf8NativePathBuf { + let path = self.path_dir(base); + if let Some(name) = name.into() { + path.join(name).with_extension(self.format.extension()) + } else { + path + } + } + + pub async fn path_create_dir( + &self, + base: impl AsRef, + name: &str, + ) -> Result { + let path = self.path_dir(base); + tokio::fs::create_dir_all(&path).await?; + Ok(path.join(name).with_extension(self.format.extension())) + } +} + +impl property::Trait for Property { + const SEEKABLE: bool = true; + + fn mime(&self) -> &'static str { + self.format.mime() + } + + fn size(&self) -> Option { + Some(self.size.into()) + } + + fn etag(&self) -> Result, Error> { + Some(u64::to_etag(&self.hash)).transpose() + } + + fn cache_control() -> CacheControl { + CacheControl::new().with_private().with_max_age(Duration::from_days(1)) + } +} + +impl property::Trait for PropertySize { + const SEEKABLE: bool = true; + + fn mime(&self) -> &'static str { + self.format.mime() + } + + fn size(&self) -> Option { + Some(self.size) + } + + fn etag(&self) -> Result, Error> { + Ok(None) + } + + fn cache_control() -> CacheControl { + Property::::cache_control() + } +} + +impl File { + pub fn new(format: F, data: Vec) -> Result { + let property = Property::new(format, &data)?; + Ok(Self { data, property }) + } +} diff --git a/nghe-backend/src/file/picture/mod.rs b/nghe-backend/src/file/picture/mod.rs new file mode 100644 index 000000000..69d5292b1 --- /dev/null +++ b/nghe-backend/src/file/picture/mod.rs @@ -0,0 +1,294 @@ +use std::borrow::Cow; + +use diesel::sql_types::Text; +use diesel::{AsExpression, FromSqlRow}; +use educe::Educe; +use lofty::picture::{MimeType, Picture as LoftyPicture}; +use nghe_api::common::format; +use o2o::o2o; +use strum::{EnumString, IntoStaticStr}; +use typed_path::{Utf8NativePath, Utf8TypedPath, Utf8TypedPathBuf}; +use uuid::Uuid; + +use super::Property; +use crate::database::Database; +use crate::filesystem::Trait as _; +use crate::orm::cover_arts; +use crate::orm::upsert::Insert; +use crate::{config, filesystem, Error}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + EnumString, + IntoStaticStr, + AsExpression, + FromSqlRow, +)] +#[diesel(sql_type = Text)] +#[strum(serialize_all = "lowercase")] +#[cfg_attr(test, derive(fake::Dummy, o2o, strum::EnumIter))] +#[cfg_attr(test, owned_into(MimeType))] +pub enum Format { + Png, + Jpeg, +} + +#[derive(o2o, Educe)] +#[educe(Debug)] +#[ref_into(cover_arts::Upsert<'s>)] +#[cfg_attr(test, derive(Clone, PartialEq, Eq))] +pub struct Picture<'s, 'd> { + #[into(~.as_ref().map(|value| value.as_str().into()))] + pub source: Option>, + #[into(~.into())] + pub property: Property, + #[ghost] + #[educe(Debug(ignore))] + pub data: Cow<'d, [u8]>, +} + +impl TryFrom<&MimeType> for Format { + type Error = Error; + + fn try_from(value: &MimeType) -> Result { + match value { + MimeType::Png => Ok(Self::Png), + MimeType::Jpeg => Ok(Self::Jpeg), + _ => Err(Self::Error::MediaPictureUnsupportedFormat(value.as_str().to_owned())), + } + } +} + +impl format::Trait for Format { + fn mime(&self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + } + } + + fn extension(&self) -> &'static str { + self.into() + } +} + +impl<'d> TryFrom<&'d LoftyPicture> for Picture<'static, 'd> { + type Error = Error; + + fn try_from(value: &'d LoftyPicture) -> Result { + Picture::new( + None, + value.mime_type().ok_or_else(|| Error::MediaPictureMissingFormat)?.try_into()?, + value.data(), + ) + } +} + +impl<'s, 'd> Picture<'s, 'd> { + pub const FILENAME: &'static str = "cover_art"; + pub const TEST_DESCRIPTION: &'static str = "nghe-picture-test-description"; + + fn new( + source: Option>, + format: Format, + data: impl Into>, + ) -> Result { + let data = data.into(); + let property = Property::new(format, &data)?; + Ok(Self { source, property, data }) + } + + pub async fn dump(&self, dir: impl AsRef) -> Result<(), Error> { + let path = self.property.path_create_dir(dir, Self::FILENAME).await?; + tokio::fs::write(path, &self.data).await?; + Ok(()) + } + + pub async fn upsert( + &self, + database: &Database, + dir: impl AsRef, + ) -> Result { + // TODO: Checking for its existence before dump. + self.dump(dir).await?; + let upsert: cover_arts::Upsert = self.into(); + upsert.insert(database).await + } + + pub async fn scan( + database: &Database, + filesystem: &filesystem::Impl<'_>, + config: &config::CoverArt, + dir: Utf8TypedPath<'_>, + ) -> Result, Error> { + if let Some(ref art_dir) = config.dir { + // TODO: Checking source before upserting. + for name in &config.names { + let path = dir.join(name); + if let Some(picture) = Picture::load(filesystem, path).await? { + return Ok(Some(picture.upsert(database, art_dir).await?)); + } + } + } + Ok(None) + } +} + +impl Picture<'static, 'static> { + pub async fn load( + filesystem: &filesystem::Impl<'_>, + source: Utf8TypedPathBuf, + ) -> Result, Error> { + let path = source.to_path(); + if filesystem.exists(path).await? { + let format = path.extension().ok_or_else(|| Error::PathExtensionMissing)?.parse()?; + let data = filesystem.read(path).await?; + return Ok(Some(Picture::new(Some(source.into_string().into()), format, data)?)); + } + Ok(None) + } +} + +#[cfg(test)] +mod test { + use std::io::Cursor; + + use concat_string::concat_string; + use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper}; + use diesel_async::RunQueryDsl; + use fake::{Dummy, Fake, Faker}; + use image::{ImageFormat, Rgb, RgbImage}; + use lofty::picture::PictureType; + + use super::*; + use crate::file; + use crate::orm::albums; + use crate::schema::songs; + use crate::test::filesystem::Trait as _; + use crate::test::{filesystem, Mock}; + + impl Format { + pub fn name(self) -> String { + concat_string!("cover", ".", std::convert::Into::<&'static str>::into(self)) + } + } + + impl Dummy for Picture<'_, '_> { + fn dummy_with_rng(config: &Faker, rng: &mut R) -> Self { + let format: Format = config.fake_with_rng(rng); + + let mut cursor = Cursor::new(vec![]); + RgbImage::from_fn( + (100..=200).fake_with_rng(rng), + (100..=200).fake_with_rng(rng), + |_, _| Rgb::from(Faker.fake_with_rng::<[u8; 3], _>(rng)), + ) + .write_to( + &mut cursor, + match format { + Format::Png => ImageFormat::Png, + Format::Jpeg => ImageFormat::Jpeg, + }, + ) + .unwrap(); + cursor.set_position(0); + + Self::new(None, format, cursor.into_inner()).unwrap() + } + } + + impl From> for LoftyPicture { + fn from(value: Picture<'_, '_>) -> Self { + Self::new_unchecked( + PictureType::Other, + Some(value.property.format.into()), + Some(Picture::TEST_DESCRIPTION.to_owned()), + value.data.into_owned(), + ) + } + } + + impl Picture<'_, '_> { + pub async fn upsert_mock(&self, mock: &Mock) -> Uuid { + self.upsert(mock.database(), mock.config.cover_art.dir.as_ref().unwrap()).await.unwrap() + } + } + + impl<'s> Picture<'s, '_> { + async fn load_cache( + dir: impl AsRef, + upsert: cover_arts::Upsert<'s>, + ) -> Self { + let property: file::Property = upsert.property.into(); + let path = property.path(dir, Self::FILENAME); + let data = tokio::fs::read(path).await.unwrap(); + Self { source: upsert.source, property, data: data.into() } + } + + pub async fn query_song(mock: &Mock, id: Uuid) -> Option { + if let Some(ref dir) = mock.config.cover_art.dir { + let upsert = cover_arts::table + .inner_join(songs::table) + .filter(songs::id.eq(id)) + .select(cover_arts::Upsert::as_select()) + .get_result(&mut mock.get().await) + .await + .optional() + .unwrap(); + if let Some(upsert) = upsert { + Some(Self::load_cache(dir, upsert).await) + } else { + None + } + } else { + None + } + } + + pub async fn query_album(mock: &Mock, id: Uuid) -> Option { + if let Some(ref dir) = mock.config.cover_art.dir { + let upsert = cover_arts::table + .inner_join(albums::table) + .filter(albums::id.eq(id)) + .select(cover_arts::Upsert::as_select()) + .get_result(&mut mock.get().await) + .await + .optional() + .unwrap(); + if let Some(upsert) = upsert { + Some(Self::load_cache(dir, upsert).await) + } else { + None + } + } else { + None + } + } + + pub fn with_source(self, source: Option>>) -> Self { + Self { source: source.map(std::convert::Into::into), ..self } + } + } + + impl Picture<'static, 'static> { + pub async fn scan_filesystem( + filesystem: &filesystem::Impl<'_>, + config: &config::CoverArt, + dir: Utf8TypedPath<'_>, + ) -> Option { + for name in &config.names { + let path = dir.join(name); + if let Some(picture) = Self::load(&filesystem.main(), path).await.unwrap() { + return Some(picture); + } + } + None + } + } +} diff --git a/nghe-backend/src/filesystem/common.rs b/nghe-backend/src/filesystem/common.rs new file mode 100644 index 000000000..f57480f91 --- /dev/null +++ b/nghe-backend/src/filesystem/common.rs @@ -0,0 +1,232 @@ +use std::borrow::Cow; + +use nghe_api::common::filesystem; +use o2o::o2o; +use typed_path::Utf8TypedPath; + +use super::{entry, path}; +use crate::file::{self, audio}; +use crate::http::binary; +use crate::Error; + +#[derive(Clone, o2o)] +#[ref_into(filesystem::Type)] +pub enum Impl<'fs> { + #[type_hint(as Unit)] + Local(Cow<'fs, super::local::Filesystem>), + #[type_hint(as Unit)] + S3(Cow<'fs, super::s3::Filesystem>), +} + +impl Impl<'_> { + pub fn path(&self) -> path::Builder { + path::Builder(self.into()) + } + + pub fn into_owned(self) -> Impl<'static> { + match self { + Impl::Local(filesystem) => Impl::Local(Cow::Owned(filesystem.into_owned())), + Impl::S3(filesystem) => Impl::S3(Cow::Owned(filesystem.into_owned())), + } + } +} + +pub trait Trait { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error>; + async fn scan_folder( + &self, + sender: entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error>; + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result; + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error>; + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result; + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result; +} + +impl Trait for Impl<'_> { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + match self { + Impl::Local(filesystem) => filesystem.check_folder(path).await, + Impl::S3(filesystem) => filesystem.check_folder(path).await, + } + } + + async fn scan_folder( + &self, + sender: entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + match self { + Impl::Local(filesystem) => filesystem.scan_folder(sender, prefix).await, + Impl::S3(filesystem) => filesystem.scan_folder(sender, prefix).await, + } + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + match self { + Impl::Local(filesystem) => filesystem.exists(path).await, + Impl::S3(filesystem) => filesystem.exists(path).await, + } + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + match self { + Impl::Local(filesystem) => filesystem.read(path).await, + Impl::S3(filesystem) => filesystem.read(path).await, + } + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + match self { + Impl::Local(filesystem) => filesystem.read_to_binary(source, offset).await, + Impl::S3(filesystem) => filesystem.read_to_binary(source, offset).await, + } + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + match self { + Impl::Local(filesystem) => filesystem.transcode_input(path).await, + Impl::S3(filesystem) => filesystem.transcode_input(path).await, + } + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use futures_lite::StreamExt; + use itertools::Itertools; + use nghe_api::common::filesystem; + use rstest::rstest; + + use super::Trait as _; + use crate::file::audio; + use crate::filesystem::{entry, Entry}; + use crate::test::filesystem::Trait as _; + use crate::test::{mock, Mock}; + + #[rstest] + #[case(filesystem::Type::Local, "usr/bin", false)] + #[case(filesystem::Type::Local, "Windows\\Sys64", false)] + #[cfg_attr(unix, case(filesystem::Type::Local, "/tmp/", true))] + #[cfg_attr(unix, case(filesystem::Type::Local, "C:\\Windows", false))] + #[cfg_attr(windows, case(filesystem::Type::Local, "C:\\Windows", true))] + #[cfg_attr(windows, case(filesystem::Type::Local, "/tmp/", false))] + #[case(filesystem::Type::S3, "usr/bin", false)] + #[case(filesystem::Type::S3, "Windows\\Sys64", false)] + #[case(filesystem::Type::S3, "/tmp", false)] + #[case(filesystem::Type::S3, "C:\\Windows", false)] + #[case(filesystem::Type::S3, "/nghe-backend-test-check-folder-bucket", true)] + #[case(filesystem::Type::S3, "/nghe-backend-test-check-folder-bucket/test/", true)] + #[tokio::test] + async fn test_check_folder( + #[future(awt)] + #[with(0, 0, Some("nghe-backend-test-check-folder-bucket"))] + mock: Mock, + #[case] ty: filesystem::Type, + #[case] path: &str, + #[case] is_ok: bool, + ) { + let filesystem = mock.to_impl(ty); + assert_eq!(filesystem.check_folder(path.into()).await.is_ok(), is_ok); + } + + #[rstest] + #[case(20, 10, 20, 15, 5)] + #[case(50, 5, 15, 10, 10)] + #[tokio::test] + async fn test_scan_folder( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + #[case] minimum_size: usize, + #[case] n_txt: usize, + #[case] n_dir: usize, + #[case] n_smaller: usize, + #[case] n_larger: usize, + ) { + let filesystem = mock.to_impl(ty); + let prefix = filesystem.prefix().to_path_buf(); + let main_filesystem = filesystem.main().into_owned(); + + let mut entries = vec![]; + + for _ in 0..n_txt { + let relative_path = filesystem.fake_path((0..3).fake()).with_extension("txt"); + let content = fake::vec![u8; 0..(2 * minimum_size)]; + filesystem.write(relative_path.to_path(), &content).await; + } + + for _ in 0..n_dir { + let relative_path = filesystem.fake_path((0..3).fake()); + filesystem.create_dir(relative_path.to_path()).await; + } + + for _ in 0..n_dir { + let relative_path = filesystem + .fake_path((0..3).fake()) + .with_extension(Faker.fake::().as_ref()); + filesystem.create_dir(relative_path.to_path()).await; + } + + for _ in 0..n_smaller { + let relative_path = filesystem + .fake_path((0..3).fake()) + .with_extension(Faker.fake::().as_ref()); + let content = fake::vec![u8; 0..minimum_size]; + filesystem.write(relative_path.to_path(), &content).await; + } + + for _ in 0..n_larger { + let format: audio::Format = Faker.fake(); + let relative_path = filesystem.fake_path((0..3).fake()).with_extension(format.as_ref()); + + let content = fake::vec![u8; ((minimum_size + 1)..(2 * minimum_size)).fake::()]; + filesystem.write(relative_path.to_path(), &content).await; + entries.push(Entry { format, path: prefix.join(relative_path), last_modified: None }); + } + + let (tx, rx) = crate::sync::channel(mock.config.filesystem.scan.channel_size); + let sender = entry::Sender { tx, minimum_size }; + let scan_handle = tokio::spawn(async move { + main_filesystem.scan_folder(sender, prefix.to_path()).await.unwrap(); + }); + let scanned_entries: Vec<_> = rx.into_stream().collect().await; + scan_handle.await.unwrap(); + + assert_eq!(scanned_entries.len(), n_larger); + assert_eq!( + scanned_entries.into_iter().sorted().collect_vec(), + entries.into_iter().sorted().collect_vec() + ); + } + + #[rstest] + #[tokio::test] + async fn test_exists( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + ) { + let filesystem = mock.to_impl(ty); + let path = filesystem.prefix().join(Faker.fake::()); + let path = path.to_path(); + assert!(!filesystem.exists(path).await.unwrap()); + filesystem.write(path, &fake::vec![u8; 10..20]).await; + assert!(filesystem.exists(path).await.unwrap()); + } +} diff --git a/nghe-backend/src/filesystem/entry.rs b/nghe-backend/src/filesystem/entry.rs new file mode 100644 index 000000000..f8458b601 --- /dev/null +++ b/nghe-backend/src/filesystem/entry.rs @@ -0,0 +1,51 @@ +use time::OffsetDateTime; +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; + +use crate::file::audio; +use crate::Error; + +pub trait Metadata { + fn size(&self) -> Result; + fn last_modified(&self) -> Result, Error>; +} + +#[derive(Debug)] +#[cfg_attr(test, derive(educe::Educe))] +#[cfg_attr(test, educe(PartialEq, Eq, PartialOrd, Ord))] +pub struct Entry { + pub format: audio::Format, + pub path: Utf8TypedPathBuf, + #[cfg_attr(test, educe(PartialEq(ignore)))] + #[cfg_attr(test, educe(PartialOrd(ignore)))] + pub last_modified: Option, +} + +impl Entry { + pub fn relative_path(&self, base: impl AsRef) -> Result, Error> { + self.path.strip_prefix(base).map_err(Error::from) + } +} + +pub struct Sender { + pub tx: loole::Sender, + pub minimum_size: usize, +} + +impl Sender { + pub async fn send( + &self, + path: Utf8TypedPathBuf, + metadata: &impl Metadata, + ) -> Result<(), Error> { + let size = metadata.size()?; + if size > self.minimum_size + && let Some(extension) = path.extension() + && let Ok(format) = audio::Format::try_from(extension) + { + self.tx + .send_async(Entry { format, path, last_modified: metadata.last_modified()? }) + .await?; + } + Ok(()) + } +} diff --git a/nghe-backend/src/filesystem/local.rs b/nghe-backend/src/filesystem/local.rs new file mode 100644 index 000000000..55f2e23bc --- /dev/null +++ b/nghe-backend/src/filesystem/local.rs @@ -0,0 +1,109 @@ +use std::fs::Metadata; + +use async_walkdir::WalkDir; +use futures_lite::stream::StreamExt; +use time::OffsetDateTime; +use typed_path::{TryAsRef as _, Utf8NativePath, Utf8TypedPath}; + +use super::{entry, path}; +use crate::file::{self, audio}; +use crate::http::binary; +use crate::Error; + +#[derive(Debug, Clone, Copy)] +pub struct Filesystem; + +impl Filesystem { + #[cfg(windows)] + fn is_native(path: &Utf8TypedPath<'_>) -> bool { + path.is_windows() + } + + #[cfg(unix)] + fn is_native(path: &Utf8TypedPath<'_>) -> bool { + path.is_unix() + } +} + +impl super::Trait for Filesystem { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + if Self::is_native(&path) + && path.is_absolute() + && tokio::fs::metadata(path.as_str()).await?.is_dir() + { + Ok(()) + } else { + Err(Error::InvalidParameter("Folder path must be absolute and be a directory")) + } + } + + async fn scan_folder( + &self, + sender: entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + let mut stream = WalkDir::new(prefix.as_ref()); + + while let Some(entry) = stream.next().await { + match entry { + Ok(entry) => match entry.metadata().await { + Ok(metadata) => { + if metadata.is_file() { + let path = entry + .path() + .into_os_string() + .into_string() + .map(path::Local::from_string) + .map_err(Error::FilesystemLocalNonUTF8PathEncountered)?; + sender.send(path, &metadata).await?; + } + } + Err(err) => tracing::error!(list_folder_local_metadata_err = ?err), + }, + Err(err) => tracing::error!(list_folder_local_walk_err = ?err), + } + } + + Ok(()) + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + tokio::fs::try_exists(path.as_str()).await.map_err(Error::from) + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + tokio::fs::read(path.as_str()).await.map_err(Error::from) + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + let path = source.path.to_path(); + let path: &Utf8NativePath = + path.try_as_ref().ok_or_else(|| Error::FilesystemTypedPathWrongPlatform)?; + binary::Response::from_path_property( + path, + &source.property, + offset, + #[cfg(test)] + None, + ) + .await + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + Ok(path.as_str().to_owned()) + } +} + +impl entry::Metadata for Metadata { + fn size(&self) -> Result { + self.len().try_into().map_err(Error::from) + } + + fn last_modified(&self) -> Result, Error> { + Ok(self.modified().ok().map(OffsetDateTime::from)) + } +} diff --git a/nghe-backend/src/filesystem/mod.rs b/nghe-backend/src/filesystem/mod.rs new file mode 100644 index 000000000..99c4c6a54 --- /dev/null +++ b/nghe-backend/src/filesystem/mod.rs @@ -0,0 +1,47 @@ +mod common; +pub mod entry; +pub mod local; +pub mod path; +pub mod s3; + +use std::borrow::Cow; + +use color_eyre::eyre::OptionExt; +pub use common::{Impl, Trait}; +pub use entry::Entry; +use nghe_api::common::filesystem; + +use crate::{config, Error}; + +#[derive(Clone)] +pub struct Filesystem { + local: local::Filesystem, + s3: Option, +} + +impl Filesystem { + pub async fn new(tls: &config::filesystem::Tls, s3: &config::filesystem::S3) -> Self { + let local = local::Filesystem; + let s3 = if s3.enable { Some(s3::Filesystem::new(tls, s3).await) } else { None }; + Self { local, s3 } + } + + pub fn to_impl(&self, ty: filesystem::Type) -> Result, Error> { + Ok(match ty { + filesystem::Type::Local => Impl::Local(Cow::Borrowed(&self.local)), + filesystem::Type::S3 => Impl::S3(Cow::Borrowed( + self.s3.as_ref().ok_or_eyre("S3 filesystem is not enabled")?, + )), + }) + } + + #[cfg(test)] + pub fn local(&self) -> local::Filesystem { + self.local + } + + #[cfg(test)] + pub fn s3(&self) -> s3::Filesystem { + self.s3.as_ref().unwrap().clone() + } +} diff --git a/nghe-backend/src/filesystem/path/builder.rs b/nghe-backend/src/filesystem/path/builder.rs new file mode 100644 index 000000000..cb05b1e84 --- /dev/null +++ b/nghe-backend/src/filesystem/path/builder.rs @@ -0,0 +1,71 @@ +#![allow(clippy::wrong_self_convention)] + +use nghe_api::common::filesystem; +use typed_path::{PathType, Utf8TypedPath, Utf8TypedPathBuf, Utf8UnixPathBuf, Utf8WindowsPathBuf}; + +#[derive(Debug, Clone, Copy)] +pub struct Const; + +#[derive(Debug, Clone, Copy)] +pub struct Builder(pub filesystem::Type); + +pub type Local = Const<{ filesystem::Type::Local }>; +pub type S3 = Const<{ filesystem::Type::S3 }>; + +macro_rules! builder_from_str { + ($ty:expr, $value:ident) => {{ + match $ty { + filesystem::Type::Local if cfg!(windows) => { + Utf8TypedPath::new($value, PathType::Windows) + } + _ => Utf8TypedPath::new($value, PathType::Unix), + } + }}; +} + +macro_rules! builder_from_string { + ($ty:expr, $value:ident) => {{ + let $value = $value.into(); + match $ty { + filesystem::Type::Local if cfg!(windows) => { + Utf8TypedPathBuf::Windows(Utf8WindowsPathBuf::from($value)) + } + _ => Utf8TypedPathBuf::Unix(Utf8UnixPathBuf::from($value)), + } + }}; +} + +#[cfg(test)] +macro_rules! builder_empty { + ($ty:expr) => { + match $ty { + filesystem::Type::Local if cfg!(windows) => Utf8TypedPathBuf::new(PathType::Windows), + _ => Utf8TypedPathBuf::new(PathType::Unix), + } + }; +} + +impl Const { + pub fn from_str(value: &(impl AsRef + ?Sized)) -> Utf8TypedPath<'_> { + builder_from_str!(TYPE, value) + } + + pub fn from_string(value: impl Into) -> Utf8TypedPathBuf { + builder_from_string!(TYPE, value) + } +} + +impl Builder { + pub fn from_str(self, value: &(impl AsRef + ?Sized)) -> Utf8TypedPath<'_> { + builder_from_str!(self.0, value) + } + + pub fn from_string(self, value: impl Into) -> Utf8TypedPathBuf { + builder_from_string!(self.0, value) + } + + #[cfg(test)] + pub fn empty(self) -> Utf8TypedPathBuf { + builder_empty!(self.0) + } +} diff --git a/nghe-backend/src/filesystem/path/mod.rs b/nghe-backend/src/filesystem/path/mod.rs new file mode 100644 index 000000000..a8112f621 --- /dev/null +++ b/nghe-backend/src/filesystem/path/mod.rs @@ -0,0 +1,4 @@ +mod builder; +pub mod serde; + +pub use builder::{Builder, Local, S3}; diff --git a/nghe-backend/src/filesystem/path/serde.rs b/nghe-backend/src/filesystem/path/serde.rs new file mode 100644 index 000000000..8dc737f8a --- /dev/null +++ b/nghe-backend/src/filesystem/path/serde.rs @@ -0,0 +1,43 @@ +use ::serde::{Deserialize, Deserializer, Serializer}; +use typed_path::Utf8NativePathBuf; + +pub fn serialize(path: &Utf8NativePathBuf, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(path.as_str()) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + ::deserialize(deserializer).map(Utf8NativePathBuf::from) +} + +pub mod option { + #![allow(clippy::ref_option)] + + use super::*; + + pub fn serialize(path: &Option, serializer: S) -> Result + where + S: Serializer, + { + if let Some(path) = path { + serializer.serialize_str(path.as_str()) + } else { + serializer.serialize_none() + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + ::deserialize(deserializer).map(|path| { + let path = Utf8NativePathBuf::from(path); + if path.is_absolute() { Some(path) } else { None } + }) + } +} diff --git a/nghe-backend/src/filesystem/s3.rs b/nghe-backend/src/filesystem/s3.rs new file mode 100644 index 000000000..6d5c34cb7 --- /dev/null +++ b/nghe-backend/src/filesystem/s3.rs @@ -0,0 +1,221 @@ +use std::time::Duration; + +use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; +use aws_config::timeout::TimeoutConfig; +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::operation::head_object::HeadObjectError; +use aws_sdk_s3::presigning::PresigningConfig; +use aws_sdk_s3::primitives::{AggregatedBytes, DateTime}; +use aws_sdk_s3::types::Object; +use aws_sdk_s3::Client; +use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder; +use concat_string::concat_string; +use hyper::client::HttpConnector; +use hyper_tls::HttpsConnector; +use time::OffsetDateTime; +use typed_path::Utf8TypedPath; + +use super::{entry, path}; +use crate::file::{self, audio}; +use crate::http::binary; +use crate::{config, Error}; + +#[derive(Debug, Clone)] +pub struct Filesystem { + client: Client, + presigned_duration: Duration, +} + +#[derive(Debug, Clone, Copy)] +pub struct Path<'b, 'k> { + pub bucket: &'b str, + pub key: &'k str, +} + +impl Filesystem { + pub async fn new(tls: &config::filesystem::Tls, s3: &config::filesystem::S3) -> Self { + let mut http_connector = HttpConnector::new(); + http_connector.enforce_http(false); + + let tls_connector = hyper_tls::native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(tls.accept_invalid_certs) + .danger_accept_invalid_hostnames(tls.accept_invalid_hostnames) + .build() + .expect("Could not build tls connector"); + + let config_loader = aws_config::from_env() + .stalled_stream_protection(if s3.stalled_stream_grace_preriod > 0 { + StalledStreamProtectionConfig::enabled() + .grace_period(Duration::from_secs(s3.stalled_stream_grace_preriod)) + .build() + } else { + StalledStreamProtectionConfig::disabled() + }) + .http_client( + HyperClientBuilder::new() + .build(HttpsConnector::from((http_connector, tls_connector.into()))), + ); + + let client = Client::from_conf( + aws_sdk_s3::config::Builder::from(&config_loader.load().await) + .force_path_style(s3.use_path_style_endpoint) + .timeout_config( + TimeoutConfig::builder() + .connect_timeout(Duration::from_secs(s3.connect_timeout)) + .build(), + ) + .build(), + ); + + Self { client, presigned_duration: Duration::from_mins(s3.presigned_duration) } + } + + pub fn split<'b, 'k, 'p: 'b + 'k>( + path: impl Into>, + ) -> Result, Error> { + let path = path.into(); + if let Utf8TypedPath::Unix(path) = path + && path.is_absolute() + && let Some(path) = path.as_str().strip_prefix('/') + { + if let Some((bucket, key)) = path.split_once('/') { + Ok(Path { bucket, key }) + } else { + Ok(Path { bucket: path, key: "" }) + } + } else { + Err(Error::FilesystemS3InvalidPath(path.to_string())) + } + } + + #[cfg(test)] + pub fn client(&self) -> &Client { + &self.client + } +} + +impl super::Trait for Filesystem { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + let Path { bucket, key } = Self::split(path)?; + self.client + .list_objects_v2() + .bucket(bucket) + .prefix(key) + .max_keys(1) + .send() + .await + .map_err(color_eyre::Report::new)?; + Ok(()) + } + + async fn scan_folder( + &self, + sender: entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + let Path { bucket, key } = Self::split(prefix)?; + let prefix = key; + let mut steam = + self.client.list_objects_v2().bucket(bucket).prefix(prefix).into_paginator().send(); + let bucket = path::S3::from_str("/").join(bucket); + + while let Some(output) = steam.try_next().await.map_err(color_eyre::Report::new)? { + if let Some(contents) = output.contents { + for content in contents { + if let Some(key) = content.key() { + sender.send(bucket.join(key), &content).await?; + } + } + } + } + + Ok(()) + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + let Path { bucket, key } = Self::split(path)?; + let result = self.client.head_object().bucket(bucket).key(key).send().await; + + if let Err(err) = result { + if let SdkError::ServiceError(ref err) = err + && let HeadObjectError::NotFound(_) = err.err() + { + Ok(false) + } else { + Err(err.into()) + } + } else { + Ok(true) + } + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + let Path { bucket, key } = Self::split(path)?; + self.client + .get_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(color_eyre::Report::new)? + .body + .collect() + .await + .map(AggregatedBytes::to_vec) + .map_err(Error::from) + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + let path = source.path.to_path(); + let Path { bucket, key } = Self::split(path)?; + let reader = self + .client + .get_object() + .bucket(bucket) + .key(key) + .set_range(offset.map(|offset| concat_string!("bytes=", offset.to_string(), "-"))) + .send() + .await? + .body + .into_async_read(); + binary::Response::from_async_read( + reader, + &source.property, + offset, + #[cfg(test)] + None, + ) + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + let Path { bucket, key } = Self::split(path)?; + Ok(self + .client + .get_object() + .bucket(bucket) + .key(key) + .presigned(PresigningConfig::expires_in(self.presigned_duration)?) + .await? + .uri() + .to_owned()) + } +} + +impl entry::Metadata for Object { + fn size(&self) -> Result { + Ok(self.size().ok_or_else(|| Error::FilesystemS3MissingObjectSize)?.try_into()?) + } + + fn last_modified(&self) -> Result, Error> { + Ok(self + .last_modified() + .map(DateTime::as_nanos) + .map(OffsetDateTime::from_unix_timestamp_nanos) + .transpose() + .map_err(color_eyre::Report::new)?) + } +} diff --git a/nghe-backend/src/http/binary/mod.rs b/nghe-backend/src/http/binary/mod.rs new file mode 100644 index 000000000..d7e816755 --- /dev/null +++ b/nghe-backend/src/http/binary/mod.rs @@ -0,0 +1,187 @@ +pub mod property; +pub mod source; + +use std::convert::Infallible; + +use axum::body::Body; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum_extra::headers::{ + AcceptRanges, ContentLength, ContentRange, HeaderMapExt, TransferEncoding, +}; +use futures_lite::{Stream, StreamExt}; +use loole::{Receiver, RecvStream}; +use nghe_api::common::format; +pub use source::Source; +use tokio::io::{AsyncRead, AsyncSeekExt, SeekFrom}; +use tokio_util::io::ReaderStream; +use typed_path::Utf8NativePath; + +#[cfg(test)] +use crate::test::transcode; +use crate::{file, Error}; + +struct RxStream(RecvStream>); + +#[derive(Debug)] +pub struct Response { + status: StatusCode, + header: HeaderMap, + body: Body, +} + +impl RxStream { + fn new(rx: Receiver>) -> Self { + Self(rx.into_stream()) + } +} + +impl Stream for RxStream { + type Item = Result, Infallible>; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.0.poll_next(cx).map(|t| t.map(Result::Ok)) + } +} + +impl From for Body { + fn from(value: RxStream) -> Self { + Self::from_stream(value) + } +} + +impl Response { + fn new( + body: Body, + property: &P, + offset: impl Into>, + #[cfg(test)] transcode_status: impl Into>, + ) -> Result { + let mut header = HeaderMap::new(); + + header.insert(header::CONTENT_TYPE, header::HeaderValue::from_static(property.mime())); + + let status = if let Some(size) = property.size() { + let offset = offset.into().unwrap_or(0); + header.typed_insert(ContentLength(size - offset)); + header.typed_insert( + ContentRange::bytes(offset.., size).map_err(color_eyre::Report::from)?, + ); + if offset == 0 { StatusCode::OK } else { StatusCode::PARTIAL_CONTENT } + } else { + header.typed_insert(TransferEncoding::chunked()); + StatusCode::OK + }; + + if let Some(etag) = property.etag()? { + header.typed_insert(etag); + } + + if P::SEEKABLE { + header.typed_insert(AcceptRanges::bytes()); + } + + header.typed_insert(P::cache_control()); + + #[cfg(test)] + if let Some(transcode_status) = transcode_status.into() { + header.typed_insert(transcode::Header(transcode_status)); + } + + Ok(Self { status, header, body }) + } + + pub async fn from_path( + path: impl AsRef, + format: impl format::Trait, + offset: impl Into> + Copy, + #[cfg(test)] transcode_status: impl Into>, + ) -> Result { + let mut file = tokio::fs::File::open(path.as_ref()).await?; + let size = file.seek(SeekFrom::End(0)).await?; + file.seek(SeekFrom::Start(offset.into().unwrap_or(0))).await?; + Self::from_async_read( + file, + &file::PropertySize { size, format }, + offset, + #[cfg(test)] + transcode_status, + ) + } + + pub async fn from_path_property( + path: impl AsRef, + property: &impl property::Trait, + offset: impl Into> + Copy, + #[cfg(test)] transcode_status: impl Into>, + ) -> Result { + let mut file = tokio::fs::File::open(path.as_ref()).await?; + if let Some(offset) = offset.into() + && offset > 0 + { + file.seek(SeekFrom::Start(offset)).await?; + } + Self::from_async_read( + file, + property, + offset, + #[cfg(test)] + transcode_status, + ) + } + + pub fn from_async_read( + reader: impl AsyncRead + Send + 'static, + property: &impl property::Trait, + offset: impl Into>, + #[cfg(test)] transcode_status: impl Into>, + ) -> Result { + Self::new( + Body::from_stream(ReaderStream::new(reader)), + property, + offset, + #[cfg(test)] + transcode_status, + ) + } + + pub fn from_rx( + rx: Receiver>, + property: format::Transcode, + #[cfg(test)] transcode_status: impl Into>, + ) -> Result { + Self::new( + RxStream::new(rx).into(), + &property, + None, + #[cfg(test)] + transcode_status, + ) + } +} + +impl IntoResponse for Response { + fn into_response(self) -> axum::response::Response { + (self.status, self.header, self.body).into_response() + } +} + +#[cfg(test)] +mod test { + use http_body_util::BodyExt; + + use super::*; + + impl Response { + pub async fn extract(self) -> (StatusCode, HeaderMap, Vec) { + let response = self.into_response(); + let status = response.status(); + let header = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes().to_vec(); + (status, header, body) + } + } +} diff --git a/nghe-backend/src/http/binary/property.rs b/nghe-backend/src/http/binary/property.rs new file mode 100644 index 000000000..a4630d493 --- /dev/null +++ b/nghe-backend/src/http/binary/property.rs @@ -0,0 +1,13 @@ +use axum_extra::headers::{CacheControl, ETag}; + +use crate::Error; + +pub trait Trait: Copy { + const SEEKABLE: bool; + + fn mime(&self) -> &'static str; + fn size(&self) -> Option; + fn etag(&self) -> Result, Error>; + + fn cache_control() -> CacheControl; +} diff --git a/nghe-backend/src/http/binary/source.rs b/nghe-backend/src/http/binary/source.rs new file mode 100644 index 000000000..c981af9a0 --- /dev/null +++ b/nghe-backend/src/http/binary/source.rs @@ -0,0 +1,34 @@ +use diesel_async::RunQueryDsl; +use typed_path::Utf8TypedPathBuf; +use uuid::Uuid; + +use super::property; +use crate::database::Database; +use crate::file::{self, audio}; +use crate::filesystem::{self, Filesystem}; +use crate::orm::binary; +use crate::Error; + +pub struct Source { + pub path: Utf8TypedPathBuf, + pub property: P, +} + +impl Source> { + pub async fn audio<'fs>( + database: &Database, + filesystem: &'fs Filesystem, + user_id: Uuid, + song_id: Uuid, + ) -> Result<(filesystem::Impl<'fs>, Self), Error> { + let audio = binary::source::audio::query(user_id, song_id) + .get_result(&mut database.get().await?) + .await?; + let filesystem = filesystem.to_impl(audio.music_folder.ty.into())?; + let path = filesystem + .path() + .from_string(audio.music_folder.path.into_owned()) + .join(audio.relative_path); + Ok((filesystem, Self { path, property: audio.property.into() })) + } +} diff --git a/nghe-backend/src/http/header.rs b/nghe-backend/src/http/header.rs new file mode 100644 index 000000000..867fd2b53 --- /dev/null +++ b/nghe-backend/src/http/header.rs @@ -0,0 +1,33 @@ +use std::ops::Bound; + +use axum_extra::headers::{ETag, Range}; +use concat_string::concat_string; + +use crate::Error; + +pub trait ToOffset { + fn to_offset(&self, size: u64) -> Result; +} + +impl ToOffset for Range { + fn to_offset(&self, size: u64) -> Result { + if let Bound::Included(offset) = + self.satisfiable_ranges(size).next().ok_or_else(|| Error::InvalidRangeHeader)?.0 + { + Ok(offset) + } else { + Err(Error::InvalidRangeHeader) + } + } +} + +pub trait ToETag: ToString { + fn to_etag(&self) -> Result { + concat_string!("\"", self.to_string(), "\"") + .parse() + .map_err(color_eyre::Report::from) + .map_err(Error::from) + } +} + +impl ToETag for u64 {} diff --git a/nghe-backend/src/http/mod.rs b/nghe-backend/src/http/mod.rs new file mode 100644 index 000000000..d6a4e217e --- /dev/null +++ b/nghe-backend/src/http/mod.rs @@ -0,0 +1,2 @@ +pub mod binary; +pub mod header; diff --git a/nghe-backend/src/lib.rs b/nghe-backend/src/lib.rs new file mode 100644 index 000000000..04991882f --- /dev/null +++ b/nghe-backend/src/lib.rs @@ -0,0 +1,74 @@ +#![feature(adt_const_params)] +#![feature(anonymous_lifetime_in_impl_trait)] +#![feature(async_closure)] +#![feature(duration_constructors)] +#![feature(integer_sign_cast)] +#![feature(iterator_try_collect)] +#![feature(let_chains)] +#![feature(proc_macro_hygiene)] +#![feature(stmt_expr_attributes)] +#![feature(str_as_str)] +#![feature(try_blocks)] + +mod auth; +pub mod config; +mod database; +mod error; +mod file; +mod filesystem; +mod http; +pub mod migration; +mod orm; +mod route; +mod scan; +mod schema; +mod sync; +mod time; +mod transcode; + +#[cfg(test)] +mod test; + +use axum::body::Body; +use axum::http::Request; +use axum::Router; +use error::Error; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +pub async fn build(config: config::Config) -> Router { + let filesystem = + filesystem::Filesystem::new(&config.filesystem.tls, &config.filesystem.s3).await; + + Router::new() + .merge(route::music_folder::router(filesystem.clone())) + .merge(route::permission::router()) + .merge(route::user::router()) + .merge(route::media_retrieval::router( + filesystem.clone(), + config.transcode, + config.cover_art.clone(), + )) + .merge(route::scan::router( + filesystem, + scan::scanner::Config { + lofty: lofty::config::ParseOptions::default(), + scan: config.filesystem.scan, + parsing: config.parsing, + index: config.index, + cover_art: config.cover_art, + }, + )) + .merge(route::browsing::router()) + .merge(route::lists::router()) + .merge(route::media_annotation::router()) + .merge(route::search::router()) + .merge(route::system::router()) + .with_state(database::Database::new(&config.database)) + .layer(TraceLayer::new_for_http().make_span_with(|request: &Request| { + tracing::info_span!( + "request", method = %request.method(), path = %request.uri().path() + ) + })) + .layer(CorsLayer::permissive()) +} diff --git a/nghe-backend/src/main.rs b/nghe-backend/src/main.rs new file mode 100644 index 000000000..708cee7b8 --- /dev/null +++ b/nghe-backend/src/main.rs @@ -0,0 +1,26 @@ +use nghe_api::constant; +use nghe_backend::{build, config, migration}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +#[tokio::main] +async fn main() { + color_eyre::install().expect("Could not install error handler"); + + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + ["nghe_backend=info".to_owned(), "tower_http=info".to_owned()].join(",").into() + })) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .try_init() + .expect("Could not install tracing handler"); + + tracing::info!(server_version =% constant::SERVER_VERSION); + + let config = config::Config::default(); + tracing::info!("{config:#?}"); + migration::run(&config.database.url).await; + + let listener = tokio::net::TcpListener::bind(config.server.to_socket_addr()).await.unwrap(); + axum::serve(listener, build(config).await).await.unwrap(); +} diff --git a/nghe-backend/src/migration/mod.rs b/nghe-backend/src/migration/mod.rs new file mode 100644 index 000000000..d7bce7c02 --- /dev/null +++ b/nghe-backend/src/migration/mod.rs @@ -0,0 +1,26 @@ +use diesel::Connection; +use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; +use diesel_async::AsyncPgConnection; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + +pub async fn run(database_url: &str) { + let database_url = database_url.to_owned(); + tokio::task::spawn_blocking(move || { + let mut async_wrapper = + AsyncConnectionWrapper::::establish(&database_url) + .expect("Could not connect to the database"); + + for migration in async_wrapper + .pending_migrations(MIGRATIONS) + .expect("Could not get pending migration(s)") + { + tracing::info!(pending_migration =% migration.name()); + async_wrapper.run_migration(&migration).expect("Could not run migration"); + } + tracing::info!("migration done"); + }) + .await + .expect("Could not spawn migration thread"); +} diff --git a/nghe-backend/src/orm/albums/date.rs b/nghe-backend/src/orm/albums/date.rs new file mode 100644 index 000000000..e19eee0cd --- /dev/null +++ b/nghe-backend/src/orm/albums/date.rs @@ -0,0 +1,52 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use nghe_api::id3; +use o2o::o2o; + +use super::albums; +use crate::Error; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +#[owned_try_into(id3::date::Date, Error)] +pub struct Date { + #[try_into(~.map(i16::try_into).transpose()?)] + pub year: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + pub month: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + pub day: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +#[owned_try_into(id3::date::Date, Error)] +pub struct Release { + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = release_year)] + pub year: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = release_month)] + pub month: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = release_day)] + pub day: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +#[owned_try_into(id3::date::Date, Error)] +pub struct OriginalRelease { + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = original_release_year)] + pub year: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = original_release_month)] + pub month: Option, + #[try_into(~.map(i16::try_into).transpose()?)] + #[diesel(column_name = original_release_day)] + pub day: Option, +} diff --git a/nghe-backend/src/orm/albums/mod.rs b/nghe-backend/src/orm/albums/mod.rs new file mode 100644 index 000000000..b0c7e885a --- /dev/null +++ b/nghe-backend/src/orm/albums/mod.rs @@ -0,0 +1,101 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::albums::{self, *}; + +pub mod date; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +#[cfg_attr(test, derive(Default))] +pub struct Foreign { + pub music_folder_id: Uuid, + pub cover_art_id: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data<'a> { + pub name: Cow<'a, str>, + #[diesel(embed)] + pub date: date::Date, + #[diesel(embed)] + pub release_date: date::Release, + #[diesel(embed)] + pub original_release_date: date::OriginalRelease, + pub mbz_id: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Upsert<'a> { + #[diesel(embed)] + pub foreign: Foreign, + #[diesel(embed)] + pub data: Data<'a>, +} + +mod upsert { + use diesel::{DecoratableTarget, ExpressionMethods}; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{albums, Upsert}; + use crate::database::Database; + use crate::Error; + + impl crate::orm::upsert::Insert for Upsert<'_> { + async fn insert(&self, database: &Database) -> Result { + if self.data.mbz_id.is_some() { + diesel::insert_into(albums::table) + .values(self) + .on_conflict((albums::music_folder_id, albums::mbz_id)) + .do_update() + .set((self, albums::scanned_at.eq(time::OffsetDateTime::now_utc()))) + .returning(albums::id) + .get_result(&mut database.get().await?) + .await + } else { + diesel::insert_into(albums::table) + .values(self) + .on_conflict(( + albums::music_folder_id, + albums::name, + albums::year, + albums::month, + albums::day, + albums::release_year, + albums::release_month, + albums::release_day, + albums::original_release_year, + albums::original_release_month, + albums::original_release_day, + )) + .filter_target(albums::mbz_id.is_null()) + .do_update() + .set(albums::scanned_at.eq(time::OffsetDateTime::now_utc())) + .returning(albums::id) + .get_result(&mut database.get().await?) + .await + } + .map_err(Error::from) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + impl From for Foreign { + fn from(value: Uuid) -> Self { + Self { music_folder_id: value, ..Default::default() } + } + } +} diff --git a/nghe-backend/src/orm/artists.rs b/nghe-backend/src/orm/artists.rs new file mode 100644 index 000000000..99f4a48fe --- /dev/null +++ b/nghe-backend/src/orm/artists.rs @@ -0,0 +1,60 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::artists::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = artists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data<'a> { + pub name: Cow<'a, str>, + pub mbz_id: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = artists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Upsert<'a> { + pub index: Cow<'a, str>, + #[diesel(embed)] + pub data: Data<'a>, +} + +mod upsert { + use diesel::{DecoratableTarget, ExpressionMethods}; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{artists, Upsert}; + use crate::database::Database; + use crate::Error; + + impl crate::orm::upsert::Insert for Upsert<'_> { + async fn insert(&self, database: &Database) -> Result { + if self.data.mbz_id.is_some() { + diesel::insert_into(artists::table) + .values(self) + .on_conflict(artists::mbz_id) + .do_update() + .set((self, artists::scanned_at.eq(time::OffsetDateTime::now_utc()))) + .returning(artists::id) + .get_result(&mut database.get().await?) + .await + } else { + diesel::insert_into(artists::table) + .values(self) + .on_conflict(artists::name) + .filter_target(artists::mbz_id.is_null()) + .do_update() + .set((self, artists::scanned_at.eq(time::OffsetDateTime::now_utc()))) + .returning(artists::id) + .get_result(&mut database.get().await?) + .await + } + .map_err(Error::from) + } + } +} diff --git a/nghe-backend/src/orm/binary/mod.rs b/nghe-backend/src/orm/binary/mod.rs new file mode 100644 index 000000000..1779b0b8a --- /dev/null +++ b/nghe-backend/src/orm/binary/mod.rs @@ -0,0 +1 @@ +pub mod source; diff --git a/nghe-backend/src/orm/binary/source/audio.rs b/nghe-backend/src/orm/binary/source/audio.rs new file mode 100644 index 000000000..02c5a16ce --- /dev/null +++ b/nghe-backend/src/orm/binary/source/audio.rs @@ -0,0 +1,30 @@ +use std::borrow::Cow; + +use diesel::dsl::{auto_type, AsSelect}; +use diesel::prelude::*; +use diesel::SelectableHelper; +use uuid::Uuid; + +use crate::orm::{albums, music_folders, permission, songs}; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +pub struct Song<'mf, 'path> { + #[diesel(embed)] + pub music_folder: music_folders::Data<'mf>, + pub relative_path: Cow<'path, str>, + #[diesel(embed)] + pub property: songs::property::File, +} + +#[auto_type] +pub fn query<'mf, 'path>(user_id: Uuid, song_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + let select_song: AsSelect, crate::orm::Type> = Song::as_select(); + albums::table + .inner_join(songs::table) + .inner_join(music_folders::table) + .filter(songs::id.eq(song_id)) + .filter(permission) + .select(select_song) +} diff --git a/nghe-backend/src/orm/binary/source/mod.rs b/nghe-backend/src/orm/binary/source/mod.rs new file mode 100644 index 000000000..fa3e4e468 --- /dev/null +++ b/nghe-backend/src/orm/binary/source/mod.rs @@ -0,0 +1 @@ +pub mod audio; diff --git a/nghe-backend/src/orm/configs.rs b/nghe-backend/src/orm/configs.rs new file mode 100644 index 000000000..e4557b441 --- /dev/null +++ b/nghe-backend/src/orm/configs.rs @@ -0,0 +1,23 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; + +pub use crate::schema::configs::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = configs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data<'a> { + pub text: Option>, + pub byte: Option>, +} + +#[derive(Debug, Insertable, AsChangeset)] +#[diesel(table_name = configs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Upsert<'a> { + pub key: &'static str, + #[diesel(embed)] + pub data: Data<'a>, +} diff --git a/nghe-backend/src/orm/cover_arts.rs b/nghe-backend/src/orm/cover_arts.rs new file mode 100644 index 000000000..563b6bb44 --- /dev/null +++ b/nghe-backend/src/orm/cover_arts.rs @@ -0,0 +1,74 @@ +use std::borrow::Cow; +use std::str::FromStr; + +use diesel::deserialize::{self, FromSql}; +use diesel::pg::PgValue; +use diesel::prelude::*; +use diesel::serialize::{self, Output, ToSql}; +use diesel::sql_types::Text; +use diesel_derives::AsChangeset; +use o2o::o2o; + +use crate::file::{self, picture}; +pub use crate::schema::cover_arts::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[map_owned(file::Property)] +#[diesel(table_name = cover_arts, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Property { + #[from(~.cast_signed())] + #[into(~.cast_unsigned())] + #[diesel(column_name = file_hash)] + pub hash: i64, + #[from(~.cast_signed())] + #[into(~.cast_unsigned())] + #[diesel(column_name = file_size)] + pub size: i32, + pub format: picture::Format, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = cover_arts, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Upsert<'s> { + pub source: Option>, + #[diesel(embed)] + pub property: Property, +} + +mod upsert { + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{cover_arts, Upsert}; + use crate::database::Database; + use crate::Error; + + impl crate::orm::upsert::Insert for Upsert<'_> { + async fn insert(&self, database: &Database) -> Result { + diesel::insert_into(cover_arts::table) + .values(self) + .on_conflict((cover_arts::source, cover_arts::file_hash, cover_arts::file_size)) + .do_update() + .set(cover_arts::format.eq(self.property.format)) + .returning(cover_arts::id) + .get_result(&mut database.get().await?) + .await + .map_err(Error::from) + } + } +} + +impl ToSql for picture::Format { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, super::Type>) -> serialize::Result { + >::to_sql(self.into(), out) + } +} + +impl FromSql for picture::Format { + fn from_sql(bytes: PgValue) -> deserialize::Result { + Ok(picture::Format::from_str(core::str::from_utf8(bytes.as_bytes())?)?) + } +} diff --git a/nghe-backend/src/orm/function.rs b/nghe-backend/src/orm/function.rs new file mode 100644 index 000000000..b5d0370a3 --- /dev/null +++ b/nghe-backend/src/orm/function.rs @@ -0,0 +1,3 @@ +use diesel::define_sql_function; + +define_sql_function!(fn random() -> Bool); diff --git a/nghe-backend/src/orm/genres.rs b/nghe-backend/src/orm/genres.rs new file mode 100644 index 000000000..4b2b7f641 --- /dev/null +++ b/nghe-backend/src/orm/genres.rs @@ -0,0 +1,13 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; + +pub use crate::schema::genres::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = genres, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data<'a> { + pub value: Cow<'a, str>, +} diff --git a/nghe-backend/src/orm/id3/album/full.rs b/nghe-backend/src/orm/id3/album/full.rs new file mode 100644 index 000000000..541bb6fc7 --- /dev/null +++ b/nghe-backend/src/orm/id3/album/full.rs @@ -0,0 +1,121 @@ +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use diesel_async::RunQueryDsl; +use nghe_api::id3; +use uuid::Uuid; + +use super::Album; +use crate::database::Database; +use crate::orm::id3::duration::Trait; +use crate::orm::id3::{artist, song}; +use crate::orm::songs; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +pub struct Full { + #[diesel(embed)] + pub album: Album, + #[diesel(embed)] + pub artists: artist::required::Artists, + #[diesel(select_expression = sql("bool_or(songs_album_artists.compilation) is_compilation"))] + #[diesel(select_expression_type = SqlLiteral::)] + pub is_compilation: bool, + #[diesel(select_expression = sql("array_agg(distinct(songs.id)) album_artists"))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub songs: Vec, +} + +impl Full { + pub async fn try_into(self, database: &Database) -> Result { + let song = song::query::unchecked() + .filter(songs::id.eq_any(self.songs)) + .get_results(&mut database.get().await?) + .await?; + let duration = song.duration()?; + let song: Vec<_> = song.into_iter().map(song::Song::try_into).try_collect()?; + + let album = self + .album + .try_into_builder()? + .song_count(song.len().try_into()?) + .duration(duration) + .build(); + + Ok(id3::album::Full { + album, + artists: self.artists.into(), + is_compilation: self.is_compilation, + song, + }) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::id3::album; + use crate::orm::{albums, permission, songs, songs_album_artists}; + + #[auto_type] + pub fn unchecked() -> _ { + let full: AsSelect = Full::as_select(); + album::query::unchecked_no_group_by() + .inner_join(songs_album_artists::table.on(songs_album_artists::song_id.eq(songs::id))) + .inner_join(artist::required::query::album()) + .group_by(albums::id) + .select(full) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + unchecked().filter(permission) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use indexmap::IndexSet; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::orm::albums; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + mock.add_music_folder().allow(allow).call().await; + let mut music_folder = mock.music_folder(0).await; + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + music_folder.add_audio().album(album.clone()).n_song((2..4).fake()).call().await; + + let database_album = query::with_user_id(mock.user_id(0).await) + .filter(albums::id.eq(album_id)) + .get_result(&mut mock.get().await) + .await; + + if allow { + let database_album = database_album.unwrap(); + assert_eq!( + database_album.songs.iter().collect::>(), + music_folder.database.keys().collect::>() + ); + } else { + assert!(database_album.is_err()); + } + } +} diff --git a/nghe-backend/src/orm/id3/album/mod.rs b/nghe-backend/src/orm/id3/album/mod.rs new file mode 100644 index 000000000..3b0c3ca99 --- /dev/null +++ b/nghe-backend/src/orm/id3/album/mod.rs @@ -0,0 +1,144 @@ +pub mod full; +pub mod short; + +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::id3; +use nghe_api::id3::builder::album as builder; +use uuid::Uuid; + +use super::genre::Genres; +use crate::orm::{albums, songs}; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = albums, check_for_backend(crate::orm::Type))] +pub struct Album { + pub id: Uuid, + pub name: String, + #[diesel(select_expression = sql( + "coalesce(albums.cover_art_id, (array_remove(array_agg(songs.cover_art_id \ + order by songs.disc_number asc nulls last, songs.track_number asc \ + nulls last, songs.cover_art_id asc), null))[1]) cover_art_id" + ))] + #[diesel(select_expression_type = SqlLiteral>)] + pub cover_art: Option, + #[diesel(embed)] + pub date: albums::date::Date, + #[diesel(column_name = mbz_id)] + pub music_brainz_id: Option, + #[diesel(embed)] + pub genres: Genres, + #[diesel(embed)] + pub original_release_date: albums::date::OriginalRelease, + #[diesel(embed)] + pub release_date: albums::date::Release, +} + +pub type BuilderSet = builder::SetReleaseDate< + builder::SetOriginalReleaseDate< + builder::SetGenres< + builder::SetMusicBrainzId< + builder::SetYear>>, + >, + >, + >, +>; + +impl Album { + pub fn try_into_builder(self) -> Result, Error> { + Ok(id3::album::Album::builder() + .id(self.id) + .name(self.name) + .cover_art(self.cover_art) + .year(self.date.year.map(u16::try_from).transpose()?) + .music_brainz_id(self.music_brainz_id) + .genres(self.genres.into()) + .original_release_date(self.original_release_date.try_into()?) + .release_date(self.release_date.try_into()?)) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::{genres, songs_genres}; + + #[auto_type] + pub fn unchecked_no_group_by() -> _ { + albums::table + .inner_join(songs::table) + .left_join(songs_genres::table.on(songs_genres::song_id.eq(songs::id))) + .left_join(genres::table.on(genres::id.eq(songs_genres::genre_id))) + .order_by(albums::name) + } + + #[auto_type] + pub fn unchecked() -> _ { + let album: AsSelect = Album::as_select(); + unchecked_no_group_by().group_by(albums::id).select(album) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::{audio, picture}; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query_cover_art( + #[future(awt)] mock: Mock, + #[values(true, false)] has_picture: bool, + #[values(true, false)] has_dir_picture: bool, + ) { + let mut music_folder = mock.music_folder(0).await; + + let (picture, picture_id) = if has_picture { + let picture: picture::Picture = Faker.fake(); + let picture_id = picture.upsert_mock(&mock).await; + (Some(picture), Some(picture_id)) + } else { + (None, None) + }; + + let (dir_picture, dir_picture_id) = if has_dir_picture { + let dir_picture: picture::Picture = Faker.fake(); + let source = music_folder.path().join(dir_picture.property.format.name()).to_string(); + let dir_picture = dir_picture.with_source(Some(source)); + let dir_picture_id = dir_picture.upsert_mock(&mock).await; + (Some(dir_picture), Some(dir_picture_id)) + } else { + (None, None) + }; + + let album: audio::Album = Faker.fake(); + + music_folder + .add_audio_filesystem::<&str>() + .album(album.clone()) + .picture(picture) + .dir_picture(dir_picture) + .depth(0) + .n_song(10) + .call() + .await; + + let album = query::unchecked().get_result(&mut mock.get().await).await.unwrap(); + if has_dir_picture { + assert_eq!(album.cover_art, dir_picture_id); + } else if has_picture { + assert_eq!(album.cover_art, picture_id); + } else { + assert!(album.cover_art.is_none()); + } + } +} diff --git a/nghe-backend/src/orm/id3/album/short.rs b/nghe-backend/src/orm/id3/album/short.rs new file mode 100644 index 000000000..1c3a13547 --- /dev/null +++ b/nghe-backend/src/orm/id3/album/short.rs @@ -0,0 +1,106 @@ +use diesel::prelude::*; +use nghe_api::id3; +use nghe_api::id3::builder::album as builder; + +use super::Album; +use crate::orm::id3::duration::Trait as _; +use crate::orm::id3::song; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +pub struct Short { + #[diesel(embed)] + pub album: Album, + #[diesel(embed)] + pub durations: song::durations::Durations, +} + +pub type BuilderSet = builder::SetDuration>; + +impl Short { + pub fn try_into_builder(self) -> Result, Error> { + Ok(self + .album + .try_into_builder()? + .song_count(self.durations.count().try_into()?) + .duration(self.durations.duration()?)) + } +} + +impl TryFrom for id3::album::Album { + type Error = Error; + + fn try_from(value: Short) -> Result { + Ok(value.try_into_builder()?.build()) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + use uuid::Uuid; + + use super::*; + use crate::orm::id3::album; + use crate::orm::{albums, permission}; + + #[auto_type] + pub fn unchecked() -> _ { + let short: AsSelect = Short::as_select(); + album::query::unchecked().select(short) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + unchecked().filter(permission) + } + + #[auto_type] + pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { + let with_user_id: with_user_id = with_user_id(user_id); + with_user_id.filter(albums::music_folder_id.eq_any(music_folder_ids)) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::orm::albums; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query(#[future(awt)] mock: Mock, #[values(0, 2)] n_genre: usize) { + let mut music_folder = mock.music_folder(0).await; + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + + let n_song = (2..4).fake(); + music_folder + .add_audio() + .album(album) + .genres(fake::vec![String; n_genre].into_iter().collect()) + .n_song(n_song) + .call() + .await; + + let database_album = query::unchecked() + .filter(albums::id.eq(album_id)) + .get_result(&mut mock.get().await) + .await + .unwrap(); + + assert_eq!(database_album.durations.count(), n_song); + assert_eq!( + database_album.durations.duration().unwrap(), + music_folder.database.duration().unwrap() + ); + assert_eq!(database_album.album.genres.value.len(), n_genre); + } +} diff --git a/nghe-backend/src/orm/id3/artist/full.rs b/nghe-backend/src/orm/id3/artist/full.rs new file mode 100644 index 000000000..c2e17e4a3 --- /dev/null +++ b/nghe-backend/src/orm/id3/artist/full.rs @@ -0,0 +1,139 @@ +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use diesel_async::RunQueryDsl; +use nghe_api::id3; +use uuid::Uuid; + +use super::super::album; +use super::Artist; +use crate::database::Database; +use crate::orm::albums; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = artists, check_for_backend(crate::orm::Type))] +pub struct Full { + #[diesel(embed)] + pub artist: Artist, + #[diesel(select_expression = sql( + "array_remove(array_agg(distinct(albums.id)), null) album_ids" + ))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub albums: Vec, +} + +impl Full { + pub async fn try_into(self, database: &Database) -> Result { + Ok(id3::artist::Full { + artist: self.artist.try_into()?, + album: album::short::query::unchecked() + .filter(albums::id.eq_any(self.albums)) + .get_results(&mut database.get().await?) + .await? + .into_iter() + .map(album::short::Short::try_into) + .try_collect()?, + }) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + use uuid::Uuid; + + use super::*; + use crate::orm::id3::artist; + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let artist: artist::query::with_user_id = artist::query::with_user_id(user_id); + let full: AsSelect = Full::as_select(); + artist.select(full) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::schema::artists; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query_artist(#[future(awt)] mock: Mock, #[values(0, 6)] n_album: i64) { + let artist: audio::Artist = Faker.fake(); + let artist_id = artist.upsert_mock(&mock).await; + + mock.add_audio_artist(0, [artist.clone()], [Faker.fake()], false, 1).await; + mock.add_audio_artist( + 0, + [Faker.fake()], + [artist.clone()], + false, + n_album.try_into().unwrap(), + ) + .await; + + let database_artist = query::with_user_id(mock.user_id(0).await) + .filter(artists::id.eq(artist_id)) + .get_result(&mut mock.get().await) + .await + .unwrap(); + + assert_eq!(database_artist.artist.album_count, n_album); + let n_album: usize = n_album.try_into().unwrap(); + assert_eq!(database_artist.albums.len(), n_album); + } + + #[rstest] + #[tokio::test] + async fn test_query_partial( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + mock.add_music_folder().allow(allow).call().await; + mock.add_music_folder().call().await; + + let artist: audio::Artist = Faker.fake(); + let artist_id = artist.upsert_mock(&mock).await; + + let n_album = (2..4).fake(); + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + mock.music_folder(0) + .await + .add_audio() + .album(album) + .artists(audio::Artists { + album: [artist.clone()].into(), + compilation: false, + ..Faker.fake() + }) + .call() + .await; + + mock.add_audio_artist(1, [Faker.fake()], [artist.clone()], false, n_album).await; + + let database_artist = query::with_user_id(mock.user_id(0).await) + .filter(artists::id.eq(artist_id)) + .get_result(&mut mock.get().await) + .await + .unwrap(); + + let n_album = if allow { n_album + 1 } else { n_album }; + assert_eq!(database_artist.albums.len(), n_album); + let n_album: i64 = n_album.try_into().unwrap(); + assert_eq!(database_artist.artist.album_count, n_album); + assert_eq!(database_artist.albums.contains(&album_id), allow); + } +} diff --git a/nghe-backend/src/orm/id3/artist/mod.rs b/nghe-backend/src/orm/id3/artist/mod.rs new file mode 100644 index 000000000..2bbc41c80 --- /dev/null +++ b/nghe-backend/src/orm/id3/artist/mod.rs @@ -0,0 +1,242 @@ +pub mod full; +pub mod required; + +use diesel::dsl::count_distinct; +use diesel::prelude::*; +use nghe_api::id3; +use nghe_api::id3::builder::artist as builder; +pub use required::Required; +use uuid::Uuid; + +use crate::orm::{albums, artists, songs}; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = artists, check_for_backend(crate::orm::Type))] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct Artist { + #[diesel(embed)] + pub required: Required, + pub index: String, + #[diesel(select_expression = count_distinct(songs::id.nullable()))] + pub song_count: i64, + #[diesel(select_expression = count_distinct(albums::id.nullable()))] + pub album_count: i64, + #[diesel(column_name = mbz_id)] + pub music_brainz_id: Option, +} + +pub type BuilderSet = + builder::SetRoles>>; + +impl Artist { + pub fn try_into_builder(self) -> Result, Error> { + let mut roles = vec![]; + if self.song_count > 0 { + roles.push(id3::artist::Role::Artist); + } + if self.album_count > 0 { + roles.push(id3::artist::Role::AlbumArtist); + } + + Ok(id3::artist::Artist::builder() + .required(self.required.into()) + .album_count(self.album_count.try_into()?) + .music_brainz_id(self.music_brainz_id) + .roles(roles)) + } +} + +impl TryFrom for id3::artist::Artist { + type Error = Error; + + fn try_from(value: Artist) -> Result { + Ok(value.try_into_builder()?.build()) + } +} + +pub mod query { + use diesel::dsl::{auto_type, exists, AsSelect}; + + use super::*; + use crate::orm::{songs_album_artists, songs_artists, user_music_folder_permissions}; + + diesel::alias!(albums as albums_sa: AlbumsSA, songs as songs_saa: SongsSAA); + + #[auto_type] + fn unchecked() -> _ { + // We will do two joins: + // + // - songs_artists -> songs -> albums. + // - use songs for extrating information. + // - use albums for checking permission -> use alias `albums_sa`. + // + // - songs_album_artists -> songs -> albums. + // - use albums for extracting information and checking permission. + // - use songs for joining -> use alias `songs_saa`. + let artist: AsSelect = Artist::as_select(); + artists::table + .left_join(songs_artists::table) + .left_join(songs::table.on(songs::id.eq(songs_artists::song_id))) + .left_join(albums_sa.on(albums_sa.field(albums::id).eq(songs::album_id))) + .left_join(songs_album_artists::table) + .left_join(songs_saa.on(songs_saa.field(songs::id).eq(songs_album_artists::song_id))) + .left_join(albums::table.on(albums::id.eq(songs_saa.field(songs::album_id)))) + .group_by(artists::id) + .select(artist) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + // Permission should be checked against `albums_sa` and `albums`. + unchecked().filter(exists( + user_music_folder_permissions::table + .filter(user_music_folder_permissions::user_id.eq(user_id)) + .filter( + user_music_folder_permissions::music_folder_id + .eq(albums_sa.field(albums::music_folder_id)) + .or(user_music_folder_permissions::music_folder_id + .eq(albums::music_folder_id)), + ), + )) + } + + #[auto_type] + pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { + // Permission should be checked against `albums_sa` and `albums`. + let with_user_id: with_user_id = with_user_id(user_id); + with_user_id.filter( + albums_sa + .field(albums::music_folder_id) + .eq_any(music_folder_ids) + .or(albums::music_folder_id.eq_any(music_folder_ids)), + ) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query_artist( + #[future(awt)] mock: Mock, + #[values(0, 5)] n_song: i64, + #[values(0, 6)] n_album: i64, + #[values(0, 7)] n_both: i64, + ) { + let artist: audio::Artist = Faker.fake(); + let artist_id = artist.upsert_mock(&mock).await; + + mock.add_audio_artist( + 0, + [artist.clone()], + [Faker.fake()], + false, + n_song.try_into().unwrap(), + ) + .await; + mock.add_audio_artist( + 0, + [Faker.fake()], + [artist.clone()], + false, + n_album.try_into().unwrap(), + ) + .await; + mock.add_audio_artist( + 0, + [artist.clone()], + [artist.clone()], + false, + n_both.try_into().unwrap(), + ) + .await; + + let database_artist = query::with_user_id(mock.user_id(0).await) + .filter(artists::id.eq(artist_id)) + .get_result(&mut mock.get().await) + .await; + + if n_song == 0 && n_album == 0 && n_both == 0 { + assert!(database_artist.is_err()); + } else { + let database_artist = database_artist.unwrap(); + assert_eq!(database_artist.song_count, n_song + n_both); + assert_eq!(database_artist.album_count, n_album + n_both); + } + } + + #[rstest] + #[tokio::test] + async fn test_query_artists( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + let music_folder_id_permission = mock.add_music_folder().allow(allow).call().await; + let music_folder_id = mock.add_music_folder().call().await; + + let user_id = mock.user_id(0).await; + let artist: audio::Artist = Faker.fake(); + let artist_id = artist.upsert_mock(&mock).await; + + let n_both: i64 = (2..4).fake(); + mock.add_audio_artist( + 0, + [artist.clone()], + [artist.clone()], + false, + n_both.try_into().unwrap(), + ) + .await; + mock.add_audio_artist(0, [Faker.fake()], [Faker.fake()], false, (2..4).fake()).await; + mock.add_audio_artist(1, [Faker.fake()], [Faker.fake()], false, (2..4).fake()).await; + + let database_artists = + query::with_user_id(user_id).get_results(&mut mock.get().await).await.unwrap(); + assert_eq!(database_artists.len(), if allow { 5 } else { 2 }); + + // Only allow music folders will be returned. + let database_artists_music_folder = + query::with_music_folder(user_id, &[music_folder_id_permission, music_folder_id]) + .get_results(&mut mock.get().await) + .await + .unwrap(); + assert_eq!(database_artists, database_artists_music_folder); + + let database_artist = + database_artists.into_iter().find(|artist| artist.required.id == artist_id); + + if allow { + let database_artist = database_artist.unwrap(); + assert_eq!(database_artist.song_count, n_both); + assert_eq!(database_artist.album_count, n_both); + } else { + assert!(database_artist.is_none()); + } + } + + #[rstest] + #[case(0, 0, &[])] + #[case(1, 0, &[id3::artist::Role::Artist])] + #[case(0, 1, &[id3::artist::Role::AlbumArtist])] + #[case(1, 1, &[id3::artist::Role::Artist, id3::artist::Role::AlbumArtist])] + fn test_try_into_api( + #[case] song_count: i64, + #[case] album_count: i64, + #[case] roles: &[id3::artist::Role], + ) { + let artist: id3::artist::Artist = + Artist { song_count, album_count, ..Faker.fake() }.try_into().unwrap(); + assert_eq!(artist.roles, roles); + } +} diff --git a/nghe-backend/src/orm/id3/artist/required.rs b/nghe-backend/src/orm/id3/artist/required.rs new file mode 100644 index 000000000..598449667 --- /dev/null +++ b/nghe-backend/src/orm/id3/artist/required.rs @@ -0,0 +1,72 @@ +use diesel::deserialize::{self, FromSql}; +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::pg::PgValue; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::id3; +use o2o::o2o; +use uuid::Uuid; + +use crate::orm::artists; + +#[derive(Debug, Queryable, Selectable, o2o)] +#[owned_into(id3::artist::Required)] +#[diesel(table_name = artists, check_for_backend(crate::orm::Type))] +#[cfg_attr(test, derive(PartialEq, Eq, fake::Dummy))] +pub struct Required { + pub id: Uuid, + pub name: String, +} + +pub type SqlType = sql_types::Record<(sql_types::Text, sql_types::Uuid)>; + +#[derive(Debug, Queryable, Selectable)] +pub struct Artists { + #[diesel(select_expression = sql( + "array_agg(distinct (artists.name, artists.id) order by (artists.name, artists.id)) artists" + ))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub value: Vec, +} + +impl From for Vec { + fn from(value: Artists) -> Self { + value.value.into_iter().map(Required::into).collect() + } +} + +pub mod query { + use diesel::dsl::auto_type; + + use super::*; + use crate::orm::{songs_album_artists, songs_artists}; + + #[auto_type] + pub fn album() -> _ { + artists::table.on(artists::id.eq(songs_album_artists::album_artist_id)) + } + + #[auto_type] + pub fn song() -> _ { + artists::table.on(artists::id.eq(songs_artists::artist_id)) + } +} + +impl FromSql for Required { + fn from_sql(bytes: PgValue) -> deserialize::Result { + let (name, id) = FromSql::::from_sql(bytes)?; + Ok(Self { id, name }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + impl From for Vec { + fn from(value: Artists) -> Self { + value.value.into_iter().map(|artist| artist.name).collect() + } + } +} diff --git a/nghe-backend/src/orm/id3/duration.rs b/nghe-backend/src/orm/id3/duration.rs new file mode 100644 index 000000000..31a10edcd --- /dev/null +++ b/nghe-backend/src/orm/id3/duration.rs @@ -0,0 +1,40 @@ +use std::ops::Add; + +use num_traits::ToPrimitive; + +use super::song; +use crate::Error; + +pub trait Trait { + fn duration(&self) -> Result; +} + +impl Trait for f32 { + fn duration(&self) -> Result { + self.ceil().to_u32().ok_or_else(|| Error::CouldNotConvertFloatToInteger(*self)) + } +} + +impl Trait for song::durations::Durations { + fn duration(&self) -> Result { + self.value + .iter() + .copied() + .reduce(song::durations::Duration::add) + .ok_or_else(|| Error::DatabaseSongDurationIsEmpty)? + .value + .duration() + } +} + +impl Trait for song::Song { + fn duration(&self) -> Result { + self.property.duration.duration() + } +} + +impl Trait for Vec { + fn duration(&self) -> Result { + self.iter().map(|song| song.property.duration).sum::().duration() + } +} diff --git a/nghe-backend/src/orm/id3/genre/mod.rs b/nghe-backend/src/orm/id3/genre/mod.rs new file mode 100644 index 000000000..70a11b020 --- /dev/null +++ b/nghe-backend/src/orm/id3/genre/mod.rs @@ -0,0 +1,23 @@ +pub mod with_count; + +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::id3; + +#[derive(Debug, Queryable, Selectable)] +pub struct Genres { + // Be careful, order by in postgresql might be case-insensitive. + #[diesel(select_expression = sql( + "array_remove(array_agg(distinct genres.value order by genres.value), null) genres" + ))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub value: Vec, +} + +impl From for id3::genre::Genres { + fn from(value: Genres) -> Self { + value.value.into_iter().collect() + } +} diff --git a/nghe-backend/src/orm/id3/genre/with_count.rs b/nghe-backend/src/orm/id3/genre/with_count.rs new file mode 100644 index 000000000..f60408ab4 --- /dev/null +++ b/nghe-backend/src/orm/id3/genre/with_count.rs @@ -0,0 +1,42 @@ +use diesel::dsl::count_distinct; +use diesel::prelude::*; +use nghe_api::id3; +use o2o::o2o; + +use crate::orm::{albums, genres, songs}; +use crate::Error; + +#[derive(Debug, Queryable, Selectable, o2o)] +#[owned_try_into(id3::genre::WithCount, Error)] +#[diesel(table_name = genres, check_for_backend(crate::orm::Type))] +pub struct WithCount { + pub value: String, + #[into(~.try_into()?)] + #[diesel(select_expression = count_distinct(songs::id))] + pub song_count: i64, + #[into(~.try_into()?)] + #[diesel(select_expression = count_distinct(albums::id))] + pub album_count: i64, +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + use uuid::Uuid; + + use super::*; + use crate::orm::{permission, songs_genres}; + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let with_count: AsSelect = WithCount::as_select(); + let permission: permission::with_album = permission::with_album(user_id); + genres::table + .inner_join(songs_genres::table) + .inner_join(songs::table.on(songs::id.eq(songs_genres::song_id))) + .inner_join(albums::table.on(albums::id.eq(songs::album_id))) + .filter(permission) + .group_by(genres::id) + .order_by(genres::value) + .select(with_count) + } +} diff --git a/nghe-backend/src/orm/id3/mod.rs b/nghe-backend/src/orm/id3/mod.rs new file mode 100644 index 000000000..12dc93fdd --- /dev/null +++ b/nghe-backend/src/orm/id3/mod.rs @@ -0,0 +1,7 @@ +#![allow(clippy::type_complexity)] + +pub mod album; +pub mod artist; +pub mod duration; +pub mod genre; +pub mod song; diff --git a/nghe-backend/src/orm/id3/song/durations.rs b/nghe-backend/src/orm/id3/song/durations.rs new file mode 100644 index 000000000..8433149a8 --- /dev/null +++ b/nghe-backend/src/orm/id3/song/durations.rs @@ -0,0 +1,46 @@ +use std::ops::Add; + +use diesel::deserialize::FromSql; +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::pg::PgValue; +use diesel::prelude::*; +use diesel::{deserialize, sql_types}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy)] +pub struct Duration { + pub value: f32, +} + +pub type SqlType = sql_types::Record<(sql_types::Uuid, sql_types::Float)>; + +#[derive(Debug, Queryable, Selectable)] +pub struct Durations { + #[diesel(select_expression = sql( + "array_agg(distinct(songs.id, songs.duration)) song_id_durations" + ))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub value: Vec, +} + +impl Add for Duration { + type Output = Duration; + + fn add(self, rhs: Self) -> Self::Output { + Self::Output { value: self.value + rhs.value } + } +} + +impl Durations { + pub fn count(&self) -> usize { + self.value.len() + } +} + +impl FromSql for Duration { + fn from_sql(bytes: PgValue) -> deserialize::Result { + let (_, value): (Uuid, f32) = FromSql::::from_sql(bytes)?; + Ok(Self { value }) + } +} diff --git a/nghe-backend/src/orm/id3/song/full.rs b/nghe-backend/src/orm/id3/song/full.rs new file mode 100644 index 000000000..53956c4b2 --- /dev/null +++ b/nghe-backend/src/orm/id3/song/full.rs @@ -0,0 +1,115 @@ +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::id3; +use o2o::o2o; +use uuid::Uuid; + +use super::Song; +use crate::orm::id3::genre; +use crate::Error; + +#[derive(Debug, Queryable, Selectable, o2o)] +#[owned_try_into(id3::song::Full, Error)] +pub struct Full { + #[into(~.try_into()?)] + #[diesel(embed)] + pub song: Song, + #[diesel(select_expression = sql("any_value(albums.name) album_name"))] + #[diesel(select_expression_type = SqlLiteral)] + pub album: String, + #[diesel(select_expression = sql("any_value(albums.id) album_id"))] + #[diesel(select_expression_type = SqlLiteral)] + pub album_id: Uuid, + #[into(~.into())] + #[diesel(embed)] + pub genres: genre::Genres, +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::id3::song; + use crate::orm::{albums, genres, permission, songs, songs_genres}; + + #[auto_type] + pub fn unchecked() -> _ { + let full: AsSelect = Full::as_select(); + song::query::unchecked_no_group_by() + .left_join(songs_genres::table.on(songs_genres::song_id.eq(songs::id))) + .left_join(genres::table.on(genres::id.eq(songs_genres::genre_id))) + .group_by(songs::id) + .select(full) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + unchecked().filter(permission) + } + + #[auto_type] + pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { + let with_user_id: with_user_id = with_user_id(user_id); + with_user_id.filter(albums::music_folder_id.eq_any(music_folder_ids)) + } +} + +#[cfg(test)] +mod test { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::orm::songs; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + #[values(0, 2)] n_genre: usize, + ) { + mock.add_music_folder().allow(allow).call().await; + let mut music_folder = mock.music_folder(0).await; + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + let artists: Vec<_> = (0..(2..4).fake()).map(|i| i.to_string()).collect(); + music_folder + .add_audio() + .album(album.clone()) + .artists(audio::Artists { + song: artists.clone().into_iter().map(String::into).collect(), + album: [Faker.fake()].into(), + compilation: false, + }) + .genres(fake::vec![String; n_genre].into_iter().collect()) + .call() + .await; + let song_id = music_folder.song_id(0); + + let database_song = query::with_user_id(mock.user_id(0).await) + .filter(songs::id.eq(song_id)) + .get_result(&mut mock.get().await) + .await; + + if allow { + let database_song = database_song.unwrap(); + let database_artists: Vec = database_song.song.artists.into(); + assert_eq!(database_song.album, album.name); + assert_eq!(database_song.album_id, album_id); + assert_eq!(database_artists, artists); + assert_eq!(database_song.genres.value.len(), n_genre); + } else { + assert!(database_song.is_err()); + } + } +} diff --git a/nghe-backend/src/orm/id3/song/mod.rs b/nghe-backend/src/orm/id3/song/mod.rs new file mode 100644 index 000000000..8a4e92771 --- /dev/null +++ b/nghe-backend/src/orm/id3/song/mod.rs @@ -0,0 +1,220 @@ +pub mod durations; +pub mod full; + +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::common::format::Trait as _; +use nghe_api::id3; +use nghe_api::id3::builder::song as builder; +use uuid::Uuid; + +use super::artist; +use super::duration::Trait as _; +use crate::orm::songs; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +pub struct Song { + pub id: Uuid, + pub title: String, + #[diesel(embed)] + pub track: songs::position::Track, + #[diesel(embed)] + pub date: songs::date::Date, + #[diesel(select_expression = sql( + "coalesce(songs.cover_art_id, any_value(albums.cover_art_id)) cover_art_id" + ))] + #[diesel(select_expression_type = SqlLiteral>)] + pub cover_art: Option, + #[diesel(embed)] + pub file: songs::property::File, + #[diesel(embed)] + pub property: songs::property::Property, + #[diesel(embed)] + pub disc: songs::position::Disc, + #[diesel(embed)] + pub artists: artist::required::Artists, + #[diesel(column_name = mbz_id)] + pub music_brainz_id: Option, +} + +pub type BuilderSet = builder::SetMusicBrainzId< + builder::SetArtists< + builder::SetDiscNumber< + builder::SetChannelCount< + builder::SetSamplingRate< + builder::SetBitDepth< + builder::SetBitRate< + builder::SetDuration< + builder::SetSuffix< + builder::SetContentType< + builder::SetSize< + builder::SetCoverArt< + builder::SetYear< + builder::SetTrack< + builder::SetTitle, + >, + >, + >, + >, + >, + >, + >, + >, + >, + >, + >, + >, + >, +>; + +impl Song { + pub fn try_into_builder(self) -> Result, Error> { + let duration = self.duration()?; + Ok(id3::song::Song::builder() + .id(self.id) + .title(self.title) + .track(self.track.number.map(u16::try_from).transpose()?) + .year(self.date.year.map(u16::try_from).transpose()?) + .cover_art(self.cover_art) + .size(self.file.size.cast_unsigned()) + .content_type(self.file.format.mime()) + .suffix(self.file.format.extension()) + .duration(duration) + .bit_rate(self.property.bitrate.try_into()?) + .bit_depth(self.property.bit_depth.map(u8::try_from).transpose()?) + .sampling_rate(self.property.sample_rate.try_into()?) + .channel_count(self.property.channel_count.try_into()?) + .disc_number(self.disc.number.map(u16::try_from).transpose()?) + .artists(self.artists.into()) + .music_brainz_id(self.music_brainz_id)) + } +} + +impl TryFrom for id3::song::Song { + type Error = Error; + + fn try_from(value: Song) -> Result { + Ok(value.try_into_builder()?.build()) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::{albums, permission, songs_artists}; + + #[auto_type] + pub fn unchecked_no_group_by() -> _ { + songs::table + .inner_join(songs_artists::table) + .inner_join(albums::table) + .inner_join(artist::required::query::song()) + .order_by(( + songs::disc_number.asc().nulls_first(), + songs::track_number.asc().nulls_first(), + songs::title.asc(), + )) + } + + #[auto_type] + pub fn unchecked() -> _ { + let song: AsSelect = Song::as_select(); + unchecked_no_group_by().group_by(songs::id).select(song) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + unchecked().filter(permission) + } + + #[auto_type] + pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { + let with_user_id: with_user_id = with_user_id(user_id); + with_user_id.filter(albums::music_folder_id.eq_any(music_folder_ids)) + } +} + +#[cfg(test)] +mod test { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::file::picture; + use crate::orm::songs; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + + music_folder.add_audio_artist(["1".into(), "2".into()], [Faker.fake()], false, 1).await; + let song_id = music_folder.song_id(0); + + let database_song = query::unchecked() + .filter(songs::id.eq(song_id)) + .get_result(&mut mock.get().await) + .await + .unwrap(); + + let artists: Vec = database_song.artists.into(); + assert_eq!(artists, &["1", "2"]); + } + + #[rstest] + #[tokio::test] + async fn test_query_cover_art( + #[future(awt)] mock: Mock, + #[values(true, false)] has_picture: bool, + #[values(true, false)] has_dir_picture: bool, + ) { + let mut music_folder = mock.music_folder(0).await; + + let (picture, picture_id) = if has_picture { + let picture: picture::Picture = Faker.fake(); + let picture_id = picture.upsert_mock(&mock).await; + (Some(picture), Some(picture_id)) + } else { + (None, None) + }; + + let (dir_picture, dir_picture_id) = if has_dir_picture { + let dir_picture: picture::Picture = Faker.fake(); + let source = music_folder.path().join(dir_picture.property.format.name()).to_string(); + let dir_picture = dir_picture.with_source(Some(source)); + let dir_picture_id = dir_picture.upsert_mock(&mock).await; + (Some(dir_picture), Some(dir_picture_id)) + } else { + (None, None) + }; + + music_folder + .add_audio_filesystem::<&str>() + .album(Faker.fake()) + .picture(picture) + .dir_picture(dir_picture) + .depth(0) + .n_song(10) + .call() + .await; + + let songs = query::unchecked().get_results(&mut mock.get().await).await.unwrap(); + for song in songs { + if has_picture { + assert_eq!(song.cover_art, picture_id); + } else if has_dir_picture { + assert_eq!(song.cover_art, dir_picture_id); + } else { + assert!(song.cover_art.is_none()); + } + } + } +} diff --git a/nghe-backend/src/orm/mod.rs b/nghe-backend/src/orm/mod.rs new file mode 100644 index 000000000..8b4082bad --- /dev/null +++ b/nghe-backend/src/orm/mod.rs @@ -0,0 +1,20 @@ +pub mod albums; +pub mod artists; +pub mod binary; +pub mod configs; +pub mod cover_arts; +pub mod function; +pub mod genres; +pub mod id3; +pub mod music_folders; +pub mod permission; +pub mod playbacks; +pub mod songs; +pub mod songs_album_artists; +pub mod songs_artists; +pub mod songs_genres; +pub mod upsert; +pub mod user_music_folder_permissions; +pub mod users; + +pub type Type = diesel::pg::Pg; diff --git a/nghe-backend/src/orm/music_folders.rs b/nghe-backend/src/orm/music_folders.rs new file mode 100644 index 000000000..b9b56b3a1 --- /dev/null +++ b/nghe-backend/src/orm/music_folders.rs @@ -0,0 +1,106 @@ +use std::borrow::Cow; + +use color_eyre::eyre::OptionExt; +use diesel::deserialize::{self, FromSql, FromSqlRow}; +use diesel::expression::AsExpression; +use diesel::pg::PgValue; +use diesel::prelude::*; +use diesel::serialize::{self, Output, ToSql}; +use diesel::sql_types::Int2; +use nghe_api::music_folder::add::Request as AddRequest; +use o2o::o2o; +use strum::FromRepr; +use uuid::Uuid; + +pub use crate::schema::music_folders::{self, *}; + +#[repr(i16)] +#[derive(Debug, Clone, Copy, FromRepr, AsExpression, FromSqlRow, PartialEq, Eq, o2o)] +#[diesel(sql_type = Int2)] +#[map_owned(nghe_api::common::filesystem::Type)] +pub enum FilesystemType { + Local = 1, + S3 = 2, +} + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = music_folders, check_for_backend(crate::orm::Type))] +pub struct Data<'a> { + pub path: Cow<'a, str>, + #[diesel(column_name = fs_type)] + pub ty: FilesystemType, +} + +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = music_folders, check_for_backend(crate::orm::Type))] +pub struct MusicFolder<'a> { + pub id: Uuid, + #[diesel(embed)] + pub data: Data<'a>, +} + +#[derive(Debug, Insertable, AsChangeset, o2o)] +#[from_ref(AddRequest)] +#[diesel(table_name = music_folders, check_for_backend(crate::orm::Type))] +pub struct Upsert<'a> { + #[from(AddRequest| Some((&~).into()))] + pub name: Option>, + #[from(AddRequest| Some((&~).into()))] + pub path: Option>, + #[from(AddRequest| Some(~.into()))] + #[diesel(column_name = fs_type)] + pub ty: Option, +} + +impl ToSql for FilesystemType { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, super::Type>) -> serialize::Result { + match self { + FilesystemType::Local => { + >::to_sql(&(FilesystemType::Local as i16), out) + } + FilesystemType::S3 => { + >::to_sql(&(FilesystemType::S3 as i16), out) + } + } + } +} + +impl FromSql for FilesystemType { + fn from_sql(bytes: PgValue) -> deserialize::Result { + Ok(FilesystemType::from_repr(i16::from_sql(bytes)?) + .ok_or_eyre("Database filesystem type constraint violation")?) + } +} + +impl Data<'_> { + pub fn into_owned(self) -> Data<'static> { + Data { path: self.path.into_owned().into(), ty: self.ty } + } +} + +impl MusicFolder<'_> { + pub fn into_owned(self) -> MusicFolder<'static> { + MusicFolder { id: self.id, data: self.data.into_owned() } + } +} + +mod query { + use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{music_folders, MusicFolder}; + use crate::database::Database; + use crate::Error; + + impl MusicFolder<'static> { + pub async fn query(database: &Database, id: Uuid) -> Result { + music_folders::table + .filter(music_folders::id.eq(id)) + .select(Self::as_select()) + .get_result(&mut database.get().await?) + .await + .map_err(Error::from) + } + } +} diff --git a/nghe-backend/src/orm/permission.rs b/nghe-backend/src/orm/permission.rs new file mode 100644 index 000000000..a1bdc04e3 --- /dev/null +++ b/nghe-backend/src/orm/permission.rs @@ -0,0 +1,23 @@ +use diesel::dsl::{auto_type, exists}; +use diesel::{ExpressionMethods, QueryDsl}; +use uuid::Uuid; + +use super::{albums, music_folders, user_music_folder_permissions}; + +#[auto_type] +pub fn with_album(user_id: Uuid) -> _ { + exists( + user_music_folder_permissions::table + .filter(user_music_folder_permissions::user_id.eq(user_id)) + .filter(user_music_folder_permissions::music_folder_id.eq(albums::music_folder_id)), + ) +} + +#[auto_type] +pub fn with_music_folder(user_id: Uuid) -> _ { + exists( + user_music_folder_permissions::table + .filter(user_music_folder_permissions::user_id.eq(user_id)) + .filter(user_music_folder_permissions::music_folder_id.eq(music_folders::id)), + ) +} diff --git a/nghe-backend/src/orm/playbacks.rs b/nghe-backend/src/orm/playbacks.rs new file mode 100644 index 000000000..18dc86cd2 --- /dev/null +++ b/nghe-backend/src/orm/playbacks.rs @@ -0,0 +1,41 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use time::OffsetDateTime; +use uuid::Uuid; + +pub use crate::schema::playbacks::{self, *}; + +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = playbacks)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Scrobble { + pub user_id: Uuid, + pub song_id: Uuid, + pub updated_at: OffsetDateTime, +} + +mod upsert { + use diesel::upsert::excluded; + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + + use super::{playbacks, Scrobble}; + use crate::database::Database; + use crate::Error; + + impl Scrobble { + pub async fn upsert(database: &Database, values: &[Self]) -> Result<(), Error> { + diesel::insert_into(playbacks::table) + .values(values) + .on_conflict((playbacks::user_id, playbacks::song_id)) + .do_update() + .set(( + playbacks::count.eq(playbacks::count + 1), + playbacks::updated_at.eq(excluded(playbacks::updated_at)), + )) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + } +} diff --git a/nghe-backend/src/orm/songs/date.rs b/nghe-backend/src/orm/songs/date.rs new file mode 100644 index 000000000..94b80dca3 --- /dev/null +++ b/nghe-backend/src/orm/songs/date.rs @@ -0,0 +1,37 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; + +use super::songs; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Date { + pub year: Option, + pub month: Option, + pub day: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Release { + #[diesel(column_name = release_year)] + pub year: Option, + #[diesel(column_name = release_month)] + pub month: Option, + #[diesel(column_name = release_day)] + pub day: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct OriginalRelease { + #[diesel(column_name = original_release_year)] + pub year: Option, + #[diesel(column_name = original_release_month)] + pub month: Option, + #[diesel(column_name = original_release_day)] + pub day: Option, +} diff --git a/nghe-backend/src/orm/songs/mod.rs b/nghe-backend/src/orm/songs/mod.rs new file mode 100644 index 000000000..a6fda5efe --- /dev/null +++ b/nghe-backend/src/orm/songs/mod.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; +use std::str::FromStr; + +use diesel::deserialize::{self, FromSql}; +use diesel::pg::PgValue; +use diesel::prelude::*; +use diesel::serialize::{self, Output, ToSql}; +use diesel::sql_types::Text; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +use crate::file::audio; +pub use crate::schema::songs::{self, *}; + +pub mod date; +pub mod name_date_mbz; +pub mod position; +pub mod property; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +#[cfg_attr(test, derive(Default))] +pub struct Foreign { + pub album_id: Uuid, + pub cover_art_id: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Song<'a> { + #[diesel(embed)] + pub main: name_date_mbz::NameDateMbz<'a>, + #[diesel(embed)] + pub track_disc: position::TrackDisc, + pub languages: Vec>>, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data<'a> { + #[diesel(embed)] + pub song: Song<'a>, + #[diesel(embed)] + pub property: property::Property, + #[diesel(embed)] + pub file: property::File, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Upsert<'a> { + #[diesel(embed)] + pub foreign: Foreign, + pub relative_path: Cow<'a, str>, + #[diesel(embed)] + pub data: Data<'a>, +} + +mod upsert { + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{songs, Upsert}; + use crate::database::Database; + use crate::Error; + + impl crate::orm::upsert::Insert for Upsert<'_> { + async fn insert(&self, database: &Database) -> Result { + diesel::insert_into(songs::table) + .values(self) + .returning(songs::id) + .get_result(&mut database.get().await?) + .await + .map_err(Error::from) + } + } + + impl crate::orm::upsert::Update for Upsert<'_> { + async fn update(&self, database: &Database, id: Uuid) -> Result<(), Error> { + diesel::update(songs::table) + .filter(songs::id.eq(id)) + .set(self) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + } + + impl crate::orm::upsert::Upsert for Upsert<'_> {} +} + +impl ToSql for audio::Format { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, super::Type>) -> serialize::Result { + >::to_sql(self.into(), out) + } +} + +impl FromSql for audio::Format { + fn from_sql(bytes: PgValue) -> deserialize::Result { + Ok(audio::Format::from_str(core::str::from_utf8(bytes.as_bytes())?)?) + } +} + +#[cfg(test)] +mod test { + use super::*; + + impl From for Foreign { + fn from(value: Uuid) -> Self { + Self { album_id: value, ..Default::default() } + } + } +} diff --git a/nghe-backend/src/orm/songs/name_date_mbz.rs b/nghe-backend/src/orm/songs/name_date_mbz.rs new file mode 100644 index 000000000..cbf2980c1 --- /dev/null +++ b/nghe-backend/src/orm/songs/name_date_mbz.rs @@ -0,0 +1,22 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +use super::{date, songs}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct NameDateMbz<'a> { + #[diesel(column_name = title)] + pub name: Cow<'a, str>, + #[diesel(embed)] + pub date: date::Date, + #[diesel(embed)] + pub release_date: date::Release, + #[diesel(embed)] + pub original_release_date: date::OriginalRelease, + pub mbz_id: Option, +} diff --git a/nghe-backend/src/orm/songs/position.rs b/nghe-backend/src/orm/songs/position.rs new file mode 100644 index 000000000..a18456d94 --- /dev/null +++ b/nghe-backend/src/orm/songs/position.rs @@ -0,0 +1,34 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; + +use super::songs; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Track { + #[diesel(column_name = track_number)] + pub number: Option, + #[diesel(column_name = track_total)] + pub total: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Disc { + #[diesel(column_name = disc_number)] + pub number: Option, + #[diesel(column_name = disc_total)] + pub total: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct TrackDisc { + #[diesel(embed)] + pub track: Track, + #[diesel(embed)] + pub disc: Disc, +} diff --git a/nghe-backend/src/orm/songs/property.rs b/nghe-backend/src/orm/songs/property.rs new file mode 100644 index 000000000..82c34b4a8 --- /dev/null +++ b/nghe-backend/src/orm/songs/property.rs @@ -0,0 +1,39 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use o2o::o2o; + +use super::songs; +use crate::file::{self, audio}; +use crate::Error; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[try_map_owned(audio::Property, Error)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Property { + pub duration: f32, + #[map(~ as _)] + pub bitrate: i32, + #[from(~.map(i16::from))] + #[into(~.map(i16::try_into).transpose()?)] + pub bit_depth: Option, + #[map(~ as _)] + pub sample_rate: i32, + #[from(~.into())] + #[into(~.try_into()?)] + pub channel_count: i16, +} + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset, o2o)] +#[map_owned(file::Property)] +#[diesel(table_name = songs, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct File { + #[map(~ as _)] + #[diesel(column_name = file_hash)] + pub hash: i64, + #[map(~ as _)] + #[diesel(column_name = file_size)] + pub size: i32, + pub format: audio::Format, +} diff --git a/nghe-backend/src/orm/songs_album_artists.rs b/nghe-backend/src/orm/songs_album_artists.rs new file mode 100644 index 000000000..18d3d9945 --- /dev/null +++ b/nghe-backend/src/orm/songs_album_artists.rs @@ -0,0 +1,14 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::songs_album_artists::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs_album_artists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data { + pub song_id: Uuid, + pub album_artist_id: Uuid, + pub compilation: bool, +} diff --git a/nghe-backend/src/orm/songs_artists.rs b/nghe-backend/src/orm/songs_artists.rs new file mode 100644 index 000000000..1c6695832 --- /dev/null +++ b/nghe-backend/src/orm/songs_artists.rs @@ -0,0 +1,13 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::songs_artists::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs_artists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data { + pub song_id: Uuid, + pub artist_id: Uuid, +} diff --git a/nghe-backend/src/orm/songs_genres.rs b/nghe-backend/src/orm/songs_genres.rs new file mode 100644 index 000000000..a9f99f63a --- /dev/null +++ b/nghe-backend/src/orm/songs_genres.rs @@ -0,0 +1,13 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::songs_genres::{self, *}; + +#[derive(Debug, Queryable, Selectable, Insertable, AsChangeset)] +#[diesel(table_name = songs_genres, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = true)] +pub struct Data { + pub song_id: Uuid, + pub genre_id: Uuid, +} diff --git a/nghe-backend/src/orm/upsert.rs b/nghe-backend/src/orm/upsert.rs new file mode 100644 index 000000000..e3ad5247e --- /dev/null +++ b/nghe-backend/src/orm/upsert.rs @@ -0,0 +1,27 @@ +use uuid::Uuid; + +use crate::database::Database; +use crate::Error; + +pub trait Insert { + async fn insert(&self, database: &Database) -> Result; +} + +pub trait Update { + async fn update(&self, database: &Database, id: Uuid) -> Result<(), Error>; +} + +pub trait Upsert: Insert + Update + Sized { + async fn upsert( + &self, + database: &Database, + id: impl Into>, + ) -> Result { + if let Some(id) = id.into() { + self.update(database, id).await?; + Ok(id) + } else { + self.insert(database).await + } + } +} diff --git a/nghe-backend/src/orm/user_music_folder_permissions.rs b/nghe-backend/src/orm/user_music_folder_permissions.rs new file mode 100644 index 000000000..2eaa9f518 --- /dev/null +++ b/nghe-backend/src/orm/user_music_folder_permissions.rs @@ -0,0 +1,11 @@ +use diesel::prelude::*; +use uuid::Uuid; + +pub use crate::schema::user_music_folder_permissions::{self, *}; + +#[derive(Insertable)] +#[diesel(table_name = user_music_folder_permissions, check_for_backend(super::Type))] +pub struct New { + pub user_id: Uuid, + pub music_folder_id: Uuid, +} diff --git a/nghe-backend/src/orm/users.rs b/nghe-backend/src/orm/users.rs new file mode 100644 index 000000000..1d5478f77 --- /dev/null +++ b/nghe-backend/src/orm/users.rs @@ -0,0 +1,48 @@ +use std::borrow::Cow; + +use diesel::prelude::*; +use o2o::o2o; +use uuid::Uuid; + +pub use crate::schema::users::{self, *}; + +#[derive(Debug, Clone, Copy, Queryable, Selectable, Insertable, o2o)] +#[diesel(table_name = users, check_for_backend(super::Type))] +#[map_owned(nghe_api::user::Role)] +pub struct Role { + #[diesel(column_name = admin_role)] + pub admin: bool, + #[diesel(column_name = stream_role)] + pub stream: bool, + #[diesel(column_name = download_role)] + pub download: bool, + #[diesel(column_name = share_role)] + pub share: bool, +} + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = users, check_for_backend(super::Type))] +pub struct Auth<'a> { + pub id: Uuid, + pub password: Cow<'a, [u8]>, + #[diesel(embed)] + pub role: Role, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = users, check_for_backend(super::Type))] +pub struct Data<'a> { + pub username: Cow<'a, str>, + pub password: Cow<'a, [u8]>, + pub email: Cow<'a, str>, + #[diesel(embed)] + pub role: Role, +} + +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = users, check_for_backend(super::Type))] +pub struct User<'a> { + pub id: Uuid, + #[diesel(embed)] + pub data: Data<'a>, +} diff --git a/nghe-backend/src/route/browsing/get_album.rs b/nghe-backend/src/route/browsing/get_album.rs new file mode 100644 index 000000000..287c574f8 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_album.rs @@ -0,0 +1,89 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::browsing::get_album::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{albums, id3}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + album: id3::album::full::query::with_user_id(user_id) + .filter(albums::id.eq(request.id)) + .get_result(&mut database.get().await?) + .await? + .try_into(database) + .await?, + }) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use id3::duration::Trait as _; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_sorted(#[future(awt)] mock: Mock, #[values(true, false)] compilation: bool) { + let mut music_folder = mock.music_folder(0).await; + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + + let n_song = (2..4).fake(); + for i in 0..n_song { + music_folder + .add_audio() + .album(album.clone()) + .artists(audio::Artists { + song: [i.to_string().into()].into(), + album: [(i + 1).to_string().into()].into(), + compilation, + }) + .song(audio::Song { + track_disc: audio::TrackDisc { + track: audio::position::Position { number: Some(i + 1), ..Faker.fake() }, + disc: audio::position::Position { number: None, ..Faker.fake() }, + }, + ..Faker.fake() + }) + .call() + .await; + } + + let album = handler(mock.database(), mock.user_id(0).await, Request { id: album_id }) + .await + .unwrap() + .album; + + let artists: Vec<_> = album.artists.into_iter().map(|artist| artist.name).collect(); + let expected_artists: Vec<_> = if compilation { + (0..=n_song).map(|i| i.to_string()).collect() + } else { + (0..n_song).map(|i| (i + 1).to_string()).collect() + }; + assert_eq!(artists, expected_artists); + assert_eq!(album.is_compilation, compilation); + + assert_eq!(album.album.duration, music_folder.database.duration().unwrap()); + + let n_song: usize = n_song.into(); + assert_eq!(album.song.len(), n_song); + for (i, song) in album.song.into_iter().enumerate() { + let track: u16 = (i + 1).try_into().unwrap(); + assert_eq!(song.track.unwrap(), track); + } + } +} diff --git a/nghe-backend/src/route/browsing/get_album_info2.rs b/nghe-backend/src/route/browsing/get_album_info2.rs new file mode 100644 index 000000000..5b8f04efe --- /dev/null +++ b/nghe-backend/src/route/browsing/get_album_info2.rs @@ -0,0 +1,25 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use nghe_api::browsing::get_album_info2::AlbumInfo; +pub use nghe_api::browsing::get_album_info2::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{albums, permission}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let music_brainz_id = albums::table + .filter(permission::with_album(user_id)) + .filter(albums::id.eq(request.id)) + .select(albums::mbz_id) + .get_result(&mut database.get().await?) + .await?; + Ok(Response { album_info: AlbumInfo { music_brainz_id } }) +} diff --git a/nghe-backend/src/route/browsing/get_artist.rs b/nghe-backend/src/route/browsing/get_artist.rs new file mode 100644 index 000000000..977d66cd1 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_artist.rs @@ -0,0 +1,73 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::browsing::get_artist::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{artists, id3}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + artist: id3::artist::full::query::with_user_id(user_id) + .filter(artists::id.eq(request.id)) + .get_result(&mut database.get().await?) + .await? + .try_into(database) + .await?, + }) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use itertools::Itertools; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_sorted(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + + let artist: audio::Artist = Faker.fake(); + let artist_id = artist.upsert_mock(&mock).await; + + let n_album = (2..4).fake(); + for i in 0..n_album { + music_folder + .add_audio() + .album(i.to_string().into()) + .artists(audio::Artists { + album: [artist.clone()].into(), + compilation: false, + ..Faker.fake() + }) + .genres(fake::vec![String; 0..4].iter().map(|genre| genre.to_lowercase()).collect()) + .call() + .await; + } + + let artist = handler(mock.database(), mock.user_id(0).await, Request { id: artist_id }) + .await + .unwrap() + .artist; + + let n_album: usize = n_album.try_into().unwrap(); + assert_eq!(artist.album.len(), n_album); + for (i, album) in artist.album.into_iter().enumerate() { + assert_eq!(album.name, i.to_string()); + let genres = album.genres.value; + assert_eq!(genres, genres.iter().cloned().unique().sorted().collect::>()); + } + } +} diff --git a/nghe-backend/src/route/browsing/get_artist_info2.rs b/nghe-backend/src/route/browsing/get_artist_info2.rs new file mode 100644 index 000000000..9b18c5bf0 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_artist_info2.rs @@ -0,0 +1,24 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use nghe_api::browsing::get_artist_info2::ArtistInfo2; +pub use nghe_api::browsing::get_artist_info2::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{artists, id3}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let music_brainz_id = id3::artist::query::with_user_id(user_id) + .filter(artists::id.eq(request.id)) + .select(artists::mbz_id) + .get_result(&mut database.get().await?) + .await?; + Ok(Response { artist_info2: ArtistInfo2 { music_brainz_id } }) +} diff --git a/nghe-backend/src/route/browsing/get_artists.rs b/nghe-backend/src/route/browsing/get_artists.rs new file mode 100644 index 000000000..e994bdadd --- /dev/null +++ b/nghe-backend/src/route/browsing/get_artists.rs @@ -0,0 +1,111 @@ +use diesel_async::RunQueryDsl; +use itertools::Itertools; +use nghe_api::browsing::get_artists::Artists; +pub use nghe_api::browsing::get_artists::{Index, Request, Response}; +use nghe_proc_macro::{check_music_folder, handler}; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::id3; +use crate::{config, Error}; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let ignored_articles = database.get_config::().await?; + + let artists = + #[check_music_folder] + id3::artist::query::with_user_id(user_id).get_results(&mut database.get().await?).await?; + + let index = artists + .into_iter() + .into_group_map_by(|artist| artist.index.clone()) + .into_iter() + .sorted_by(|lhs, rhs| Ord::cmp(&lhs.0, &rhs.0)) + .map(|(name, artist)| { + Ok::<_, Error>(Index { + name, + artist: artist + .into_iter() + .sorted_by(|lhs, rhs| Ord::cmp(&lhs.required.name, &rhs.required.name)) + .map(id3::artist::Artist::try_into) + .try_collect()?, + }) + }) + .try_collect()?; + + Ok(Response { artists: Artists { ignored_articles, index } }) +} + +#[cfg(test)] +mod tests { + use concat_string::concat_string; + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_sorted(#[future(awt)] mock: Mock) { + mock.add_audio_artist( + 0, + ["A1".into(), "A2".into(), "C1".into(), "C2".into()], + ["B1".into(), "B2".into()], + false, + 1, + ) + .await; + + let index = + handler(mock.database(), mock.user_id(0).await, Request { music_folder_ids: None }) + .await + .unwrap() + .artists + .index; + + for (i, index) in index.into_iter().enumerate() { + let name = + char::from_u32((b'A' + u8::try_from(i).unwrap()).into()).unwrap().to_string(); + assert_eq!(index.name, name); + for (j, artist) in index.artist.into_iter().enumerate() { + let name = concat_string!(&name, (j + 1).to_string()); + assert_eq!(artist.required.name, name); + } + } + } + + #[rstest] + #[tokio::test] + async fn test_check_music_folder( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + ) { + mock.add_music_folder().allow(false).call().await; + mock.add_music_folder().call().await; + + let mut music_folder_deny = mock.music_folder(0).await; + let mut music_folder_allow = mock.music_folder(1).await; + music_folder_deny.add_audio().n_song(10).call().await; + music_folder_allow.add_audio().n_song(10).call().await; + + let user_id = mock.user_id(0).await; + let with_user_id = + handler(mock.database(), user_id, Request { music_folder_ids: None }).await.unwrap(); + let with_music_folder = handler( + mock.database(), + user_id, + Request { + music_folder_ids: Some(vec![music_folder_deny.id(), music_folder_allow.id()]), + }, + ) + .await + .unwrap(); + assert_eq!(with_user_id, with_music_folder); + } +} diff --git a/nghe-backend/src/route/browsing/get_genres.rs b/nghe-backend/src/route/browsing/get_genres.rs new file mode 100644 index 000000000..a012e86d8 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_genres.rs @@ -0,0 +1,79 @@ +use diesel_async::RunQueryDsl; +use nghe_api::browsing::get_genres::Genres; +pub use nghe_api::browsing::get_genres::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::id3; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + genres: Genres { + genre: id3::genre::with_count::query::with_user_id(user_id) + .get_results(&mut database.get().await?) + .await? + .into_iter() + .map(id3::genre::with_count::WithCount::try_into) + .try_collect()?, + }, + }) +} + +#[cfg(test)] +mod test { + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + mock.add_music_folder().allow(allow).call().await; + mock.add_music_folder().call().await; + + let mut music_folder_permission = mock.music_folder(0).await; + let mut music_folder = mock.music_folder(1).await; + + let genre: String = Faker.fake(); + + let n_song_permission = (2..4).fake(); + let n_song = (2..4).fake(); + + music_folder_permission + .add_audio() + .genres([genre.clone(), Faker.fake()].into_iter().collect()) + .n_song(n_song_permission) + .call() + .await; + music_folder + .add_audio() + .genres([genre.clone(), Faker.fake()].into_iter().collect()) + .n_song(n_song) + .call() + .await; + + let genres = + handler(mock.database(), mock.user_id(0).await, Request {}).await.unwrap().genres.genre; + assert_eq!(genres.len(), if allow { 3 } else { 2 }); + + let genre = genres.into_iter().find(|with_count| with_count.value == genre).unwrap(); + let count: u32 = + (if allow { n_song_permission + n_song } else { n_song }).try_into().unwrap(); + assert_eq!(genre.song_count, count); + assert_eq!(genre.album_count, count); + } +} diff --git a/nghe-backend/src/route/browsing/get_music_folders.rs b/nghe-backend/src/route/browsing/get_music_folders.rs new file mode 100644 index 000000000..c385b4dfc --- /dev/null +++ b/nghe-backend/src/route/browsing/get_music_folders.rs @@ -0,0 +1,66 @@ +use diesel::QueryDsl; +use diesel_async::RunQueryDsl; +pub use nghe_api::browsing::get_music_folders::{MusicFolder, MusicFolders, Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{music_folders, permission}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + music_folders: MusicFolders { + music_folder: music_folders::table + .filter(permission::with_music_folder(user_id)) + .order_by(music_folders::created_at) + .select((music_folders::id, music_folders::name)) + .get_results::<(Uuid, String)>(&mut database.get().await?) + .await? + .into_iter() + .map(|(id, name)| MusicFolder { id, name }) + .collect(), + }, + }) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_handler( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + let music_folder_id_permission = mock.add_music_folder().allow(allow).call().await; + let music_folder_id = mock.add_music_folder().call().await; + + let user_id = mock.user_id(0).await; + let music_folders = handler(mock.database(), user_id, Request {}) + .await + .unwrap() + .music_folders + .music_folder + .into_iter() + .map(|music_folder| music_folder.id) + .collect::>(); + + if allow { + assert_eq!(music_folders, &[music_folder_id_permission, music_folder_id]); + } else { + assert_eq!(music_folders, &[music_folder_id]); + } + } +} diff --git a/nghe-backend/src/route/browsing/get_song.rs b/nghe-backend/src/route/browsing/get_song.rs new file mode 100644 index 000000000..095290909 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_song.rs @@ -0,0 +1,73 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::browsing::get_song::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{id3, songs}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + song: id3::song::full::query::with_user_id(user_id) + .filter(songs::id.eq(request.id)) + .get_result(&mut database.get().await?) + .await? + .try_into()?, + }) +} + +#[cfg(test)] +mod test { + use fake::{Fake, Faker}; + use itertools::Itertools; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_sorted(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + + let album: audio::Album = Faker.fake(); + let album_id = album.upsert_mock(&mock, 0).await; + let artists: Vec<_> = (0..(2..4).fake()).map(|i| i.to_string()).collect(); + music_folder + .add_audio() + .album(album.clone()) + .artists(audio::Artists { + song: artists.clone().into_iter().map(String::into).collect(), + album: [Faker.fake()].into(), + compilation: false, + }) + .genres(fake::vec![String; 0..4].iter().map(|genre| genre.to_lowercase()).collect()) + .call() + .await; + let song_id = music_folder.song_id(0); + + let database_song = + handler(mock.database(), mock.user_id(0).await, Request { id: song_id }) + .await + .unwrap() + .song; + + assert_eq!(database_song.album, album.name); + assert_eq!(database_song.album_id, album_id); + + let database_artists: Vec<_> = + database_song.song.artists.into_iter().map(|artist| artist.name).collect(); + assert_eq!(database_artists, artists); + + let genres = database_song.genres.value; + assert_eq!(genres, genres.iter().cloned().unique().sorted().collect::>()); + } +} diff --git a/nghe-backend/src/route/browsing/get_top_songs.rs b/nghe-backend/src/route/browsing/get_top_songs.rs new file mode 100644 index 000000000..bca04dd92 --- /dev/null +++ b/nghe-backend/src/route/browsing/get_top_songs.rs @@ -0,0 +1,35 @@ +use diesel::dsl::sum; +use diesel::{ExpressionMethods, JoinOnDsl, PgSortExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; +use nghe_api::browsing::get_top_songs::TopSongs; +pub use nghe_api::browsing::get_top_songs::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{artists, id3, playbacks, songs}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + // Since each playback count is accounted for a user, we make a sum here to get the total + // playback count. + Ok(Response { + top_songs: TopSongs { + song: id3::song::full::query::with_user_id(user_id) + .filter(artists::name.eq(request.artist)) + .left_join(playbacks::table.on(playbacks::song_id.eq(songs::id))) + .order_by(sum(playbacks::count).desc().nulls_last()) + .limit(request.count.unwrap_or(50).into()) + .get_results(&mut database.get().await?) + .await? + .into_iter() + .map(id3::song::full::Full::try_into) + .try_collect()?, + }, + }) +} diff --git a/nghe-backend/src/route/browsing/mod.rs b/nghe-backend/src/route/browsing/mod.rs new file mode 100644 index 000000000..b2c5f18df --- /dev/null +++ b/nghe-backend/src/route/browsing/mod.rs @@ -0,0 +1,23 @@ +mod get_album; +mod get_album_info2; +mod get_artist; +mod get_artist_info2; +mod get_artists; +mod get_genres; +mod get_music_folders; +mod get_song; +mod get_top_songs; + +nghe_proc_macro::build_router! { + modules = [ + get_album, + get_album_info2, + get_artist, + get_artist_info2, + get_artists, + get_genres, + get_music_folders, + get_song, + get_top_songs + ], +} diff --git a/nghe-backend/src/route/lists/get_album_list2.rs b/nghe-backend/src/route/lists/get_album_list2.rs new file mode 100644 index 000000000..71e3354ec --- /dev/null +++ b/nghe-backend/src/route/lists/get_album_list2.rs @@ -0,0 +1,87 @@ +use diesel::dsl::{max, sum}; +use diesel::{ExpressionMethods, JoinOnDsl, PgSortExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; +use nghe_api::lists::get_album_list2::{AlbumList2, ByYear, Type}; +pub use nghe_api::lists::get_album_list2::{Request, Response}; +use nghe_proc_macro::{check_music_folder, handler}; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{albums, function, genres, id3, playbacks, songs}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + #[check_music_folder] + { + let query = id3::album::short::query::with_user_id(user_id) + .limit(request.size.unwrap_or(10).into()) + .offset(request.offset.unwrap_or(0).into()); + + let albums = match request.ty { + Type::Random => { + query.order_by(function::random()).get_results(&mut database.get().await?).await? + } + Type::Newest => { + query + .order_by(albums::created_at.desc()) + .get_results(&mut database.get().await?) + .await? + } + Type::Frequent => { + // Since each playback count is accounted for a song, we make a sum here to get the + // playback count of the whole album. + query + .inner_join(playbacks::table.on(playbacks::song_id.eq(songs::id))) + .filter(playbacks::user_id.eq(user_id)) + .order_by(sum(playbacks::count).desc().nulls_last()) + .get_results(&mut database.get().await?) + .await? + } + Type::Recent => { + query + .inner_join(playbacks::table.on(playbacks::song_id.eq(songs::id))) + .filter(playbacks::user_id.eq(user_id)) + .order_by(max(playbacks::updated_at).desc().nulls_last()) + .get_results(&mut database.get().await?) + .await? + } + Type::AlphabeticalByName => query.get_results(&mut database.get().await?).await?, + Type::ByYear(ByYear { from_year, to_year }) => { + let from_year: i16 = from_year.try_into()?; + let to_year: i16 = to_year.try_into()?; + if from_year < to_year { + query + .filter(albums::year.ge(from_year)) + .filter(albums::year.le(to_year)) + .order_by(albums::year.asc()) + .get_results(&mut database.get().await?) + .await? + } else { + query + .filter(albums::year.ge(to_year)) + .filter(albums::year.le(from_year)) + .order_by(albums::year.desc()) + .get_results(&mut database.get().await?) + .await? + } + } + Type::ByGenre { genre } => { + query + .filter(genres::value.eq(genre)) + .get_results(&mut database.get().await?) + .await? + } + }; + + Ok(Response { + album_list2: AlbumList2 { + album: albums.into_iter().map(id3::album::short::Short::try_into).try_collect()?, + }, + }) + } +} diff --git a/nghe-backend/src/route/lists/get_random_songs.rs b/nghe-backend/src/route/lists/get_random_songs.rs new file mode 100644 index 000000000..56c766c99 --- /dev/null +++ b/nghe-backend/src/route/lists/get_random_songs.rs @@ -0,0 +1,48 @@ +use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use nghe_api::lists::get_random_songs::RandomSong; +pub use nghe_api::lists::get_random_songs::{Request, Response}; +use nghe_proc_macro::{check_music_folder, handler}; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{albums, function, genres, id3, songs}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + #[check_music_folder] + { + let mut query = id3::song::full::query::with_user_id(user_id) + .limit(request.size.unwrap_or(10).into()) + .order_by(function::random()) + .into_boxed(); + + if let Some(genre) = request.genre { + query = query.filter(genres::value.eq(genre)); + } + if let Some(from_year) = request.from_year { + let from_year: i16 = from_year.try_into()?; + query = query.filter(songs::year.ge(from_year).or(albums::year.ge(from_year))); + } + if let Some(to_year) = request.to_year { + let to_year: i16 = to_year.try_into()?; + query = query.filter(songs::year.le(to_year).or(albums::year.le(to_year))); + } + + Ok(Response { + random_songs: RandomSong { + song: query + .get_results(&mut database.get().await?) + .await? + .into_iter() + .map(id3::song::full::Full::try_into) + .try_collect()?, + }, + }) + } +} diff --git a/nghe-backend/src/route/lists/mod.rs b/nghe-backend/src/route/lists/mod.rs new file mode 100644 index 000000000..5cfdb9118 --- /dev/null +++ b/nghe-backend/src/route/lists/mod.rs @@ -0,0 +1,6 @@ +mod get_album_list2; +mod get_random_songs; + +nghe_proc_macro::build_router! { + modules = [get_album_list2, get_random_songs], +} diff --git a/nghe-backend/src/route/media_annotation/mod.rs b/nghe-backend/src/route/media_annotation/mod.rs new file mode 100644 index 000000000..6a2df490e --- /dev/null +++ b/nghe-backend/src/route/media_annotation/mod.rs @@ -0,0 +1,5 @@ +mod scrobble; + +nghe_proc_macro::build_router! { + modules = [scrobble], +} diff --git a/nghe-backend/src/route/media_annotation/scrobble.rs b/nghe-backend/src/route/media_annotation/scrobble.rs new file mode 100644 index 000000000..95cac10c1 --- /dev/null +++ b/nghe-backend/src/route/media_annotation/scrobble.rs @@ -0,0 +1,136 @@ +use itertools::{EitherOrBoth, Itertools}; +pub use nghe_api::media_annotation::scrobble::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::playbacks; +use crate::Error; + +const MILLIS_TO_NANOS: i128 = 1_000_000; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let submission = request.submission.unwrap_or(true); + if submission { + let values: Vec<_> = request + .ids + .into_iter() + .zip_longest(request.times.unwrap_or_default()) + .map(|data| match data { + EitherOrBoth::Both(song_id, updated_at) => { + let updated_at: i128 = updated_at.into(); + let updated_at = time::OffsetDateTime::from_unix_timestamp_nanos( + updated_at * MILLIS_TO_NANOS, + )?; + Ok(playbacks::Scrobble { user_id, song_id, updated_at }) + } + EitherOrBoth::Left(song_id) => Ok(playbacks::Scrobble { + user_id, + song_id, + updated_at: time::OffsetDateTime::now_utc(), + }), + EitherOrBoth::Right(_) => Err(Error::ScrobbleRequestMustHaveBeMoreIdThanTime), + }) + .try_collect()?; + playbacks::Scrobble::upsert(database, &values).await?; + } + + Ok(Response) +} + +#[cfg(test)] +mod tests { + use diesel::{ExpressionMethods, QueryDsl}; + use diesel_async::RunQueryDsl; + use fake::faker::time::en::*; + use fake::{Fake, Faker}; + use rstest::rstest; + use time::macros::datetime; + use time::OffsetDateTime; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_scrobble( + #[future(awt)] mock: Mock, + #[values(2, 5)] n_song: usize, + #[values(0, 3)] n_time: usize, + #[values(10, 20)] n_play: usize, + ) { + let user_id = mock.user_id(0).await; + + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song(n_song).call().await; + let ids: Vec<_> = music_folder.database.keys().copied().collect(); + + let start_dt = datetime!(2000-01-01 0:00 UTC); + let end_dt = datetime!(2020-01-01 0:00 UTC); + let times: Vec<_> = (0..n_time) + .map(|_| { + DateTimeBetween(start_dt, end_dt) + .fake::() + .replace_microsecond(0) + .unwrap() + }) + .collect(); + + for i in 0..n_play { + let times_u64 = if i < n_play - 1 { + if Faker.fake() { + Some(fake::vec![ + u64 as 1_000_000_000_000..2_000_000_000_000; + 0..(n_song - (0..2).fake::()) + ]) + } else { + None + } + } else { + Some( + times + .iter() + .map(|time| { + (time.unix_timestamp_nanos() / MILLIS_TO_NANOS).try_into().unwrap() + }) + .collect(), + ) + }; + + let result = handler( + mock.database(), + user_id, + Request { ids: ids.clone(), times: times_u64, submission: None }, + ) + .await; + assert_eq!(result.is_ok(), i < n_play - 1 || n_song >= n_time); + } + + for (i, id) in ids.into_iter().enumerate() { + let (count, time) = playbacks::table + .filter(playbacks::user_id.eq(user_id)) + .filter(playbacks::song_id.eq(id)) + .select((playbacks::count, playbacks::updated_at)) + .get_result::<(i32, OffsetDateTime)>(&mut mock.get().await) + .await + .unwrap(); + + let count: usize = count.try_into().unwrap(); + assert_eq!(count, if n_song >= n_time { n_play } else { n_play - 1 }); + + if n_song >= n_time { + if i >= n_time { + let now = OffsetDateTime::now_utc(); + assert!((now - time).as_seconds_f32() < 1.0); + } else { + assert_eq!(time, times[i]); + } + } + } + } +} diff --git a/nghe-backend/src/route/media_retrieval/download.rs b/nghe-backend/src/route/media_retrieval/download.rs new file mode 100644 index 000000000..468efffab --- /dev/null +++ b/nghe-backend/src/route/media_retrieval/download.rs @@ -0,0 +1,107 @@ +use axum_extra::headers::Range; +pub use nghe_api::media_retrieval::download::Request; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::file::{self, audio}; +use crate::filesystem::{self, Filesystem, Trait}; +use crate::http::binary; +use crate::http::header::ToOffset; +use crate::Error; + +pub async fn handler_impl( + filesystem: filesystem::Impl<'_>, + source: binary::Source>, + offset: Option, +) -> Result { + filesystem.read_to_binary(&source, offset).await +} + +#[handler(role = download, headers = [range])] +pub async fn handler( + database: &Database, + filesystem: &Filesystem, + range: Option, + user_id: Uuid, + request: Request, +) -> Result { + let (filesystem, source) = + binary::Source::audio(database, filesystem, user_id, request.id).await?; + let offset = range.map(|range| range.to_offset(source.property.size.into())).transpose()?; + handler_impl(filesystem, source, offset).await +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use axum_extra::headers::{ + AcceptRanges, CacheControl, ContentLength, ContentRange, ETag, HeaderMapExt, + }; + use binary::property::Trait as _; + use nghe_api::common::filesystem; + use rstest::rstest; + use xxhash_rust::xxh3::xxh3_64; + + use super::*; + use crate::file::audio; + use crate::http::header::ToETag; + use crate::test::{mock, Mock}; + + #[rstest] + #[case(None)] + #[case(Some(0))] + #[case(Some(500))] + #[tokio::test] + async fn test_download( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + #[values(true, false)] allow: bool, + #[case] offset: Option, + ) { + mock.add_music_folder().ty(ty).allow(allow).call().await; + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().format(audio::Format::Flac).call().await; + + let local_bytes = + music_folder.to_impl().read(music_folder.absolute_path(0).to_path()).await.unwrap(); + let local_hash = xxh3_64(&local_bytes); + let local_bytes = &local_bytes[offset.unwrap_or(0).try_into().unwrap()..]; + + let range = offset.map(|offset| Range::bytes(offset..).unwrap()); + let user_id = mock.user_id(0).await; + let request = Request { id: music_folder.song_id_filesystem(0).await }; + let binary = handler(mock.database(), mock.filesystem(), range, user_id, request).await; + + assert_eq!(binary.is_ok(), allow); + + if allow { + let binary = binary.unwrap(); + let (status, headers, body) = binary.extract().await; + + let body_len: u64 = body.len().try_into().unwrap(); + let offset = offset.unwrap_or(0); + + assert_eq!( + status, + if offset == 0 { StatusCode::OK } else { StatusCode::PARTIAL_CONTENT } + ); + + assert_eq!(headers.typed_get::().unwrap().0, body_len); + assert_eq!( + headers.typed_get::().unwrap(), + ContentRange::bytes(offset.., Some(offset + body_len)).unwrap() + ); + assert_eq!(headers.typed_get::().unwrap(), local_hash.to_etag().unwrap()); + assert_eq!(headers.typed_get::().unwrap(), AcceptRanges::bytes()); + assert_eq!( + headers.typed_get::().unwrap(), + file::Property::::cache_control() + ); + + assert_eq!(body, local_bytes); + } + } +} diff --git a/nghe-backend/src/route/media_retrieval/get_cover_art.rs b/nghe-backend/src/route/media_retrieval/get_cover_art.rs new file mode 100644 index 000000000..13e634b7c --- /dev/null +++ b/nghe-backend/src/route/media_retrieval/get_cover_art.rs @@ -0,0 +1,37 @@ +use axum_extra::headers::Range; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +pub use nghe_api::media_retrieval::get_cover_art::Request; +use nghe_proc_macro::handler; + +use crate::database::Database; +use crate::file::{self, picture}; +use crate::http::binary; +use crate::http::header::ToOffset; +use crate::orm::cover_arts; +use crate::{config, Error}; + +#[handler(role = download, headers = [range])] +pub async fn handler( + database: &Database, + range: Option, + config: config::CoverArt, + request: Request, +) -> Result { + let dir = &config.dir.ok_or_else(|| Error::MediaCoverArtDirIsNotEnabled)?; + let property: file::Property = cover_arts::table + .filter(cover_arts::id.eq(request.id)) + .select(cover_arts::Property::as_select()) + .get_result(&mut database.get().await?) + .await? + .into(); + let offset = range.map(|range| range.to_offset(property.size.into())).transpose()?; + binary::Response::from_path_property( + property.path(dir, picture::Picture::FILENAME), + &property, + offset, + #[cfg(test)] + None, + ) + .await +} diff --git a/nghe-backend/src/route/media_retrieval/mod.rs b/nghe-backend/src/route/media_retrieval/mod.rs new file mode 100644 index 000000000..27eb41c00 --- /dev/null +++ b/nghe-backend/src/route/media_retrieval/mod.rs @@ -0,0 +1,11 @@ +pub mod download; +mod get_cover_art; +mod stream; + +use crate::config; + +nghe_proc_macro::build_router! { + modules = [download, get_cover_art, stream], + filesystem = true, + extensions = [config::Transcode, config::CoverArt], +} diff --git a/nghe-backend/src/route/media_retrieval/stream.rs b/nghe-backend/src/route/media_retrieval/stream.rs new file mode 100644 index 000000000..032ca7202 --- /dev/null +++ b/nghe-backend/src/route/media_retrieval/stream.rs @@ -0,0 +1,259 @@ +use axum_extra::headers::Range; +pub use nghe_api::media_retrieval::stream::{Format, Request}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use super::download; +use crate::database::Database; +use crate::filesystem::{Filesystem, Trait}; +use crate::http::binary; +use crate::http::header::ToOffset; +#[cfg(test)] +use crate::test::transcode::Status as TranscodeStatus; +use crate::{config, transcode, Error}; + +#[handler(role = stream, headers = [range])] +pub async fn handler( + database: &Database, + filesystem: &Filesystem, + range: Option, + config: config::Transcode, + user_id: Uuid, + request: Request, +) -> Result { + let (filesystem, source) = + binary::Source::audio(database, filesystem, user_id, request.id).await?; + let size_offset = + range.map(|range| range.to_offset(source.property.size.into())).transpose()?; + + let bitrate = request.max_bit_rate.unwrap_or(32); + let time_offset = request.time_offset.unwrap_or(0); + + let format = match request.format.unwrap_or_default() { + Format::Raw => return download::handler_impl(filesystem, source, size_offset).await, + Format::Transcode(format) => format, + }; + let property = source.property.replace(format); + let source_path = source.path.to_path(); + + let transcode_config = if let Some(ref cache_dir) = config.cache_dir { + let output = property.path_create_dir(cache_dir, bitrate.to_string().as_str()).await?; + + let span = tracing::Span::current(); + let (can_acquire_lock, output) = tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + (transcode::Sink::lock_read(&output).is_ok(), output) + }) + .await?; + + // If local cache is turned on and we can acquire the read lock, it means that: + // - The file exists. + // - No process is writing to it. The transcoding process is finish. + // + // In that case, we have two cases: + // - If time offset is greater than 0, we can use the transcoded file as transcoder input + // so it only needs to activate `atrim` filter. + // - Otherwise, we only need to stream the transcoded file from local cache. + // If the lock can not be acquired, we have two cases: + // - If time offset is greater than 0, we spawn a transcoding process without writing it + // back to the local cache. + // - Otherwise, we spawn a transcoding process and let the sink tries acquiring the write + // lock for further processing. + if can_acquire_lock { + if time_offset > 0 { + ( + output.as_str().to_owned(), + None, + #[cfg(test)] + TranscodeStatus::UseCachedOutput, + ) + } else { + return binary::Response::from_path( + output, + format, + size_offset, + #[cfg(test)] + TranscodeStatus::ServeCachedOutput, + ) + .await; + } + } else { + ( + filesystem.transcode_input(source_path).await?, + if time_offset > 0 { None } else { Some(output) }, + #[cfg(test)] + if time_offset > 0 { TranscodeStatus::NoCache } else { TranscodeStatus::WithCache }, + ) + } + } else { + ( + filesystem.transcode_input(source_path).await?, + None, + #[cfg(test)] + TranscodeStatus::NoCache, + ) + }; + + let input = transcode_config.0; + let output = transcode_config.1; + + let (sink, rx) = transcode::Sink::new(&config, format, output).await?; + #[cfg(test)] + let transcode_status = sink.status(transcode_config.2); + transcode::Transcoder::spawn(input, sink, bitrate, time_offset)?; + + binary::Response::from_rx( + rx, + format, + #[cfg(test)] + transcode_status, + ) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use axum_extra::headers::HeaderMapExt; + use itertools::Itertools; + use nghe_api::common::{filesystem, format}; + use rstest::rstest; + + use super::*; + use crate::file::audio; + use crate::test::transcode::{Header as TranscodeHeader, Status as TranscodeStatus}; + use crate::test::{mock, Mock}; + + async fn spawn_stream( + mock: &Mock, + n_task: usize, + user_id: Uuid, + request: Request, + ) -> (Vec<(StatusCode, Vec)>, Vec) { + let mut stream_set = tokio::task::JoinSet::new(); + for _ in 0..n_task { + let database = mock.database().clone(); + let filesystem = mock.filesystem().clone(); + let config = mock.config.transcode.clone(); + stream_set.spawn(async move { + handler(&database, &filesystem, None, config, user_id, request) + .await + .unwrap() + .extract() + .await + }); + } + let (responses, transcode_status): (Vec<_>, Vec<_>) = stream_set + .join_all() + .await + .into_iter() + .map(|(status, headers, body)| { + ((status, body), headers.typed_get::().unwrap().0) + }) + .unzip(); + (responses, transcode_status.into_iter().sorted().collect()) + } + + #[rstest] + #[tokio::test] + async fn test_stream( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + ) { + mock.add_music_folder().ty(ty).call().await; + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().format(audio::Format::Flac).call().await; + + let user_id = mock.user_id(0).await; + let song_id = music_folder.song_id_filesystem(0).await; + let format = format::Transcode::Opus; + let bitrate = 32; + + let transcoded = { + let path = music_folder.absolute_path(0); + let input = music_folder.to_impl().transcode_input(path.to_path()).await.unwrap(); + let config = &mock.config.transcode; + transcode::Transcoder::spawn_collect(&input, config, format, bitrate, 0).await + }; + + let request = Request { + id: song_id, + max_bit_rate: Some(bitrate), + format: Some(format.into()), + time_offset: None, + }; + + let (responses, transcode_status) = spawn_stream(&mock, 2, user_id, request).await; + for (status, body) in responses { + assert_eq!(status, StatusCode::OK); + assert_eq!(transcoded, body); + } + assert_eq!(transcode_status, &[TranscodeStatus::NoCache, TranscodeStatus::WithCache]); + + let (responses, transcode_status) = spawn_stream(&mock, 2, user_id, request).await; + for (status, body) in responses { + assert_eq!(status, StatusCode::OK); + assert_eq!(transcoded, body); + } + assert_eq!( + transcode_status, + &[TranscodeStatus::ServeCachedOutput, TranscodeStatus::ServeCachedOutput] + ); + } + + #[rstest] + #[tokio::test] + async fn test_stream_time_offset( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + ) { + mock.add_music_folder().ty(ty).call().await; + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().format(audio::Format::Flac).call().await; + + let user_id = mock.user_id(0).await; + let song_id = music_folder.song_id_filesystem(0).await; + let format = format::Transcode::Opus; + let bitrate = 32; + let time_offset = 10; + + let transcoded = { + let path = music_folder.absolute_path(0); + let input = music_folder.to_impl().transcode_input(path.to_path()).await.unwrap(); + let config = &mock.config.transcode; + transcode::Transcoder::spawn_collect(&input, config, format, bitrate, time_offset).await + }; + + let request = Request { + id: song_id, + max_bit_rate: Some(bitrate), + format: Some(format.into()), + time_offset: Some(time_offset), + }; + + let (responses, transcode_status) = spawn_stream(&mock, 2, user_id, request).await; + for (status, body) in responses { + assert_eq!(status, StatusCode::OK); + assert_eq!(transcoded, body); + } + assert_eq!(transcode_status, &[TranscodeStatus::NoCache, TranscodeStatus::NoCache]); + + let transcode_status = + spawn_stream(&mock, 1, user_id, Request { time_offset: None, ..request }).await.1; + assert_eq!(transcode_status, &[TranscodeStatus::WithCache]); + + // We don't test the response body here because it does not take the same input as above. + // However, we want to make sure that the transcode status is equal to `UseCachedOutput`. + let (responses, transcode_status) = spawn_stream(&mock, 2, user_id, request).await; + for (status, _) in responses { + assert_eq!(status, StatusCode::OK); + } + assert_eq!( + transcode_status, + &[TranscodeStatus::UseCachedOutput, TranscodeStatus::UseCachedOutput] + ); + } +} diff --git a/nghe-backend/src/route/mod.rs b/nghe-backend/src/route/mod.rs new file mode 100644 index 000000000..84e4dcd06 --- /dev/null +++ b/nghe-backend/src/route/mod.rs @@ -0,0 +1,10 @@ +pub mod browsing; +pub mod lists; +pub mod media_annotation; +pub mod media_retrieval; +pub mod music_folder; +pub mod permission; +pub mod scan; +pub mod search; +pub mod system; +pub mod user; diff --git a/nghe-backend/src/route/music_folder/add.rs b/nghe-backend/src/route/music_folder/add.rs new file mode 100644 index 000000000..5df7985e4 --- /dev/null +++ b/nghe-backend/src/route/music_folder/add.rs @@ -0,0 +1,62 @@ +use diesel_async::RunQueryDsl; +pub use nghe_api::music_folder::add::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::error::Error; +use crate::filesystem::{self, Filesystem, Trait as _}; +use crate::orm::music_folders; +use crate::route::permission; + +async fn handler_impl( + database: &Database, + filesystem: filesystem::Impl<'_>, + request: Request, +) -> Result { + filesystem.check_folder(request.path.as_str().into()).await?; + + let music_folder_id = diesel::insert_into(music_folders::table) + .values(music_folders::Upsert::from(&request)) + .returning(music_folders::id) + .get_result::(&mut database.get().await?) + .await?; + + if request.allow { + permission::add::handler( + database, + permission::add::Request { user_id: None, music_folder_id: Some(music_folder_id) }, + ) + .await?; + } + + Ok(Response { music_folder_id }) +} + +#[handler(role = admin)] +pub async fn handler( + database: &Database, + filesystem: &Filesystem, + request: Request, +) -> Result { + handler_impl(database, filesystem.to_impl(request.ty)?, request).await +} + +#[cfg(test)] +mod tests { + use nghe_api::common::filesystem; + use rstest::rstest; + + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_add( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + #[values(filesystem::Type::Local, filesystem::Type::S3)] ty: filesystem::Type, + ) { + mock.add_music_folder().ty(ty).call().await; + } +} diff --git a/nghe-backend/src/route/music_folder/mod.rs b/nghe-backend/src/route/music_folder/mod.rs new file mode 100644 index 000000000..0876564ab --- /dev/null +++ b/nghe-backend/src/route/music_folder/mod.rs @@ -0,0 +1,6 @@ +pub mod add; + +nghe_proc_macro::build_router! { + modules = [add], + filesystem = true, +} diff --git a/nghe-backend/src/route/permission/add.rs b/nghe-backend/src/route/permission/add.rs new file mode 100644 index 000000000..f9d77ec65 --- /dev/null +++ b/nghe-backend/src/route/permission/add.rs @@ -0,0 +1,101 @@ +use diesel::{sql_types, IntoSql, JoinOnDsl, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::permission::add::{Request, Response}; +use nghe_proc_macro::handler; + +use crate::database::Database; +use crate::orm::{music_folders, user_music_folder_permissions, users}; +use crate::Error; + +#[handler(role = admin)] +pub async fn handler(database: &Database, request: Request) -> Result { + let Request { user_id, music_folder_id } = request; + + if let Some(user_id) = user_id { + if let Some(music_folder_id) = music_folder_id { + let new = user_music_folder_permissions::New { user_id, music_folder_id }; + + diesel::insert_into(user_music_folder_permissions::table) + .values(new) + .on_conflict_do_nothing() + .execute(&mut database.get().await?) + .await?; + } else { + let new = music_folders::table + .select((user_id.into_sql::(), music_folders::id)); + + diesel::insert_into(user_music_folder_permissions::table) + .values(new) + .into_columns(( + user_music_folder_permissions::user_id, + user_music_folder_permissions::music_folder_id, + )) + .on_conflict_do_nothing() + .execute(&mut database.get().await?) + .await?; + } + } else if let Some(music_folder_id) = music_folder_id { + let new = users::table.select((users::id, music_folder_id.into_sql::())); + + diesel::insert_into(user_music_folder_permissions::table) + .values(new) + .into_columns(( + user_music_folder_permissions::user_id, + user_music_folder_permissions::music_folder_id, + )) + .on_conflict_do_nothing() + .execute(&mut database.get().await?) + .await?; + } else if cfg!(test) { + let new = users::table + .inner_join(music_folders::table.on(true.into_sql::())) + .select((users::id, music_folders::id)); + + diesel::insert_into(user_music_folder_permissions::table) + .values(new) + .into_columns(( + user_music_folder_permissions::user_id, + user_music_folder_permissions::music_folder_id, + )) + .on_conflict_do_nothing() + .execute(&mut database.get().await?) + .await?; + } else { + return Err(Error::InvalidParameter( + "The fields `user_id` and `music_folder_id` can not be both empty", + )); + } + + Ok(Response) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::test::route::permission::{count, reset}; + use crate::test::{mock, Mock}; + + #[rstest] + #[case(true, true, 1)] + #[case(true, false, 3)] + #[case(false, true, 2)] + #[case(false, false, 6)] + #[tokio::test] + async fn test_add( + #[future(awt)] + #[with(2, 3)] + mock: Mock, + #[case] with_user: bool, + #[case] with_music_folder: bool, + #[case] permission_count: usize, + ) { + reset(&mock).await; + let user_id = if with_user { Some(mock.user_id(0).await) } else { None }; + let music_folder_id = + if with_music_folder { Some(mock.music_folder_id(0).await) } else { None }; + assert!(handler(mock.database(), Request { user_id, music_folder_id }).await.is_ok()); + assert_eq!(count(&mock).await, permission_count); + } +} diff --git a/nghe-backend/src/route/permission/mod.rs b/nghe-backend/src/route/permission/mod.rs new file mode 100644 index 000000000..1ea12c8ef --- /dev/null +++ b/nghe-backend/src/route/permission/mod.rs @@ -0,0 +1,5 @@ +pub mod add; + +nghe_proc_macro::build_router! { + modules = [add], +} diff --git a/nghe-backend/src/route/scan/mod.rs b/nghe-backend/src/route/scan/mod.rs new file mode 100644 index 000000000..5e2dfb1d6 --- /dev/null +++ b/nghe-backend/src/route/scan/mod.rs @@ -0,0 +1,9 @@ +mod start; + +use crate::scan::scanner; + +nghe_proc_macro::build_router! { + modules = [start], + filesystem = true, + extensions = [scanner::Config], +} diff --git a/nghe-backend/src/route/scan/start.rs b/nghe-backend/src/route/scan/start.rs new file mode 100644 index 000000000..8ec2daeaa --- /dev/null +++ b/nghe-backend/src/route/scan/start.rs @@ -0,0 +1,24 @@ +pub use nghe_api::scan::start::{Request, Response}; +use nghe_proc_macro::handler; +use tracing::Instrument; + +use crate::database::Database; +use crate::filesystem::Filesystem; +use crate::scan::scanner; +use crate::Error; + +#[handler(role = admin)] +pub async fn handler( + database: &Database, + filesystem: &Filesystem, + config: scanner::Config, + request: Request, +) -> Result { + let scanner = scanner::Scanner::new(database, filesystem, config, request.music_folder_id) + .await? + .into_owned(); + + let span = tracing::Span::current(); + tokio::task::spawn(async move { scanner.run().await }.instrument(span)); + Ok(Response) +} diff --git a/nghe-backend/src/route/search/mod.rs b/nghe-backend/src/route/search/mod.rs new file mode 100644 index 000000000..a1daa66f3 --- /dev/null +++ b/nghe-backend/src/route/search/mod.rs @@ -0,0 +1,5 @@ +mod search3; + +nghe_proc_macro::build_router! { + modules = [search3], +} diff --git a/nghe-backend/src/route/search/search3.rs b/nghe-backend/src/route/search/search3.rs new file mode 100644 index 000000000..7438d8058 --- /dev/null +++ b/nghe-backend/src/route/search/search3.rs @@ -0,0 +1,133 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use diesel_full_text_search::configuration::TsConfigurationByName; +use diesel_full_text_search::{ + ts_rank_cd, websearch_to_tsquery_with_search_config, TsVectorExtensions, +}; +use nghe_api::search::search3::SearchResult3; +pub use nghe_api::search::search3::{Request, Response}; +use nghe_proc_macro::{check_music_folder, handler}; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{albums, artists, id3, songs}; +use crate::Error; + +const USIMPLE_TS_CONFIGURATION: TsConfigurationByName = TsConfigurationByName("usimple"); + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let search_query = &request.query; + let sync = search_query.is_empty() || search_query == "\"\""; + + #[check_music_folder] + { + let count = request.artist_count.unwrap_or(20).into(); + let artist = if count > 0 { + let offset = request.artist_offset.unwrap_or(0).into(); + let query = id3::artist::query::with_user_id(user_id).limit(count).offset(offset); + if sync { + query + .order_by((artists::name, artists::mbz_id)) + .get_results(&mut database.get().await?) + .await? + } else { + query + .filter(artists::ts.matches(websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ))) + .order_by( + ts_rank_cd( + artists::ts, + websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ), + ) + .desc(), + ) + .get_results(&mut database.get().await?) + .await? + } + } else { + vec![] + }; + + let count = request.album_count.unwrap_or(20).into(); + let album = if count > 0 { + let offset = request.album_offset.unwrap_or(0).into(); + let query = id3::album::short::query::with_user_id(user_id).limit(count).offset(offset); + if sync { + query + .order_by((albums::name, albums::mbz_id)) + .get_results(&mut database.get().await?) + .await? + } else { + query + .filter(albums::ts.matches(websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ))) + .order_by( + ts_rank_cd( + albums::ts, + websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ), + ) + .desc(), + ) + .get_results(&mut database.get().await?) + .await? + } + } else { + vec![] + }; + + let count = request.song_count.unwrap_or(20).into(); + let song = if count > 0 { + let offset = request.song_offset.unwrap_or(0).into(); + let query = id3::song::query::with_user_id(user_id).limit(count).offset(offset); + if sync { + query + .order_by((songs::title, songs::mbz_id)) + .get_results(&mut database.get().await?) + .await? + } else { + query + .filter(songs::ts.matches(websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ))) + .order_by( + ts_rank_cd( + songs::ts, + websearch_to_tsquery_with_search_config( + USIMPLE_TS_CONFIGURATION, + search_query, + ), + ) + .desc(), + ) + .get_results(&mut database.get().await?) + .await? + } + } else { + vec![] + }; + + Ok(Response { + search_result3: SearchResult3 { + artist: artist.into_iter().map(id3::artist::Artist::try_into).try_collect()?, + album: album.into_iter().map(id3::album::short::Short::try_into).try_collect()?, + song: song.into_iter().map(id3::song::Song::try_into).try_collect()?, + }, + }) + } +} diff --git a/nghe-backend/src/route/system/get_open_subsonic_extensions.rs b/nghe-backend/src/route/system/get_open_subsonic_extensions.rs new file mode 100644 index 000000000..07a323e56 --- /dev/null +++ b/nghe-backend/src/route/system/get_open_subsonic_extensions.rs @@ -0,0 +1,17 @@ +use nghe_api::system::get_open_subsonic_extensions::Extension; +pub use nghe_api::system::get_open_subsonic_extensions::{Request, Response}; +use nghe_proc_macro::handler; + +use crate::database::Database; +use crate::Error; + +static EXTENSIONS: &[Extension] = &[ + Extension { name: "transcodeOffset", versions: &[1] }, + Extension { name: "songLyrics", versions: &[1] }, + Extension { name: "formPost", versions: &[1] }, +]; + +#[handler(need_auth = false, binary = false)] +pub async fn handler(_database: &Database, request: Request) -> Result { + Ok(Response { open_subsonic_extensions: EXTENSIONS }) +} diff --git a/nghe-backend/src/route/system/mod.rs b/nghe-backend/src/route/system/mod.rs new file mode 100644 index 000000000..90f8bd1d9 --- /dev/null +++ b/nghe-backend/src/route/system/mod.rs @@ -0,0 +1,6 @@ +mod get_open_subsonic_extensions; +mod ping; + +nghe_proc_macro::build_router! { + modules = [get_open_subsonic_extensions { binary: false }, ping], +} diff --git a/nghe-backend/src/route/system/ping.rs b/nghe-backend/src/route/system/ping.rs new file mode 100644 index 000000000..3a193e80b --- /dev/null +++ b/nghe-backend/src/route/system/ping.rs @@ -0,0 +1,10 @@ +pub use nghe_api::system::ping::{Request, Response}; +use nghe_proc_macro::handler; + +use crate::database::Database; +use crate::Error; + +#[handler] +pub async fn handler(_database: &Database, request: Request) -> Result { + Ok(Response) +} diff --git a/nghe-backend/src/route/user/create.rs b/nghe-backend/src/route/user/create.rs new file mode 100644 index 000000000..9b7d4e541 --- /dev/null +++ b/nghe-backend/src/route/user/create.rs @@ -0,0 +1,54 @@ +use diesel_async::RunQueryDsl; +pub use nghe_api::user::create::{Request, Response}; +use nghe_proc_macro::handler; + +use crate::database::Database; +use crate::orm::users; +use crate::route::permission; +use crate::Error; + +#[handler(role = admin)] +pub async fn handler(database: &Database, request: Request) -> Result { + let Request { username, password, email, role, allow } = request; + let password = database.encrypt(password); + + let user_id = diesel::insert_into(users::table) + .values(users::Data { + username: username.into(), + password: password.into(), + email: email.into(), + role: role.into(), + }) + .returning(users::id) + .get_result(&mut database.get().await?) + .await?; + + if allow { + permission::add::handler( + database, + permission::add::Request { user_id: Some(user_id), music_folder_id: None }, + ) + .await?; + } + Ok(Response { user_id }) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_create_user( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + ) { + let user_id = handler(mock.database(), Faker.fake()).await.unwrap().user_id; + assert_eq!(mock.user_id(0).await, user_id); + } +} diff --git a/nghe-backend/src/route/user/mod.rs b/nghe-backend/src/route/user/mod.rs new file mode 100644 index 000000000..74f053ef5 --- /dev/null +++ b/nghe-backend/src/route/user/mod.rs @@ -0,0 +1,6 @@ +pub mod create; +mod setup; + +nghe_proc_macro::build_router! { + modules = [create, setup], +} diff --git a/nghe-backend/src/route/user/setup.rs b/nghe-backend/src/route/user/setup.rs new file mode 100644 index 000000000..0aa89b8b2 --- /dev/null +++ b/nghe-backend/src/route/user/setup.rs @@ -0,0 +1,51 @@ +use diesel::QueryDsl; +use diesel_async::RunQueryDsl; +pub use nghe_api::user::setup::{Request, Response}; +use nghe_api::user::Role; +use nghe_proc_macro::handler; + +use super::create; +use crate::database::Database; +use crate::orm::users; +use crate::Error; + +#[handler(need_auth = false)] +pub async fn handler(database: &Database, request: Request) -> Result { + if users::table.count().first::(&mut database.get().await?).await? > 0 { + Err(Error::Unauthorized("Could not access setup endpoint when there is already one user")) + } else { + let Request { username, password, email } = request; + create::handler( + database, + create::Request { + username, + password, + email, + role: Role { admin: true, stream: true, download: true, share: true }, + allow: false, + }, + ) + .await?; + Ok(Response) + } +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_setup( + #[future(awt)] + #[with(0, 0)] + mock: Mock, + ) { + assert!(handler(mock.database(), Faker.fake()).await.is_ok()); + assert!(handler(mock.database(), Faker.fake()).await.is_err()); + } +} diff --git a/nghe-backend/src/scan/mod.rs b/nghe-backend/src/scan/mod.rs new file mode 100644 index 000000000..a8df477f0 --- /dev/null +++ b/nghe-backend/src/scan/mod.rs @@ -0,0 +1 @@ +pub mod scanner; diff --git a/nghe-backend/src/scan/scanner.rs b/nghe-backend/src/scan/scanner.rs new file mode 100644 index 000000000..d7e97645e --- /dev/null +++ b/nghe-backend/src/scan/scanner.rs @@ -0,0 +1,416 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use diesel::{ExpressionMethods, NullableExpressionMethods, OptionalExtension, QueryDsl}; +use diesel_async::RunQueryDsl; +use lofty::config::ParseOptions; +use loole::Receiver; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; +use tracing::{instrument, Instrument}; +use typed_path::Utf8TypedPath; +use uuid::Uuid; + +use crate::database::Database; +use crate::file::{self, audio, picture, File}; +use crate::filesystem::{self, entry, Entry, Filesystem, Trait}; +use crate::orm::{albums, music_folders, songs}; +use crate::{config, Error}; + +#[derive(Debug, Clone)] +pub struct Config { + pub lofty: ParseOptions, + pub scan: config::filesystem::Scan, + pub parsing: config::Parsing, + pub index: config::Index, + pub cover_art: config::CoverArt, +} + +#[derive(Clone)] +pub struct Scanner<'db, 'fs, 'mf> { + pub database: Cow<'db, Database>, + pub filesystem: filesystem::Impl<'fs>, + pub config: Config, + pub music_folder: music_folders::MusicFolder<'mf>, +} + +impl<'db, 'fs, 'mf> Scanner<'db, 'fs, 'mf> { + pub async fn new( + database: &'db Database, + filesystem: &'fs Filesystem, + config: Config, + music_folder_id: Uuid, + ) -> Result { + Self::new_orm( + database, + filesystem, + config, + music_folders::MusicFolder::query(database, music_folder_id).await?, + ) + } + + pub fn new_orm( + database: &'db Database, + filesystem: &'fs Filesystem, + config: Config, + music_folder: music_folders::MusicFolder<'mf>, + ) -> Result { + let filesystem = filesystem.to_impl(music_folder.data.ty.into())?; + Ok(Self { database: Cow::Borrowed(database), filesystem, config, music_folder }) + } + + pub fn into_owned(self) -> Scanner<'static, 'static, 'static> { + Scanner { + database: Cow::Owned(self.database.into_owned()), + filesystem: self.filesystem.into_owned(), + music_folder: self.music_folder.into_owned(), + ..self + } + } + + fn path(&self) -> Utf8TypedPath { + self.filesystem.path().from_str(&self.music_folder.data.path) + } + + fn relative_path<'entry>(&self, entry: &'entry Entry) -> Result, Error> { + entry.path.strip_prefix(&self.music_folder.data.path).map_err(Error::from) + } + + fn init(&self) -> (JoinHandle>, Arc, Receiver) { + let config = self.config.scan; + let (tx, rx) = crate::sync::channel(config.channel_size); + let filesystem = self.filesystem.clone().into_owned(); + let sender = entry::Sender { tx, minimum_size: config.minimum_size }; + let prefix = self.path().to_path_buf(); + ( + tokio::spawn(async move { filesystem.scan_folder(sender, prefix.to_path()).await }), + Arc::new(Semaphore::const_new(config.pool_size)), + rx, + ) + } + + async fn set_scanned_at( + &self, + entry: &Entry, + ) -> Result, Error> { + let song_path = diesel::alias!(songs as song_path); + diesel::update(songs::table) + .filter( + songs::id.nullable().eq(song_path + .inner_join(albums::table) + .filter(albums::music_folder_id.eq(self.music_folder.id)) + .filter( + song_path + .field(songs::relative_path) + .eq(entry.relative_path(&self.music_folder.data.path)?.as_str()), + ) + .select(song_path.field(songs::id)) + .single_value()), + ) + .set(songs::scanned_at.eq(time::OffsetDateTime::now_utc())) + .returning((songs::id, songs::updated_at)) + .get_result(&mut self.database.get().await?) + .await + .optional() + .map_err(Error::from) + } + + async fn query_hash_size( + &self, + property: &file::Property, + ) -> Result, Error> { + songs::table + .inner_join(albums::table) + .filter(albums::music_folder_id.eq(self.music_folder.id)) + .filter(songs::file_hash.eq(property.hash.cast_signed())) + .filter(songs::file_size.eq(property.size.cast_signed())) + .select((songs::id, songs::relative_path)) + .get_result(&mut self.database.get().await?) + .await + .optional() + .map_err(Error::from) + } + + #[instrument(skip(self, started_at), ret(level = "debug"), err)] + async fn one(&self, entry: &Entry, started_at: time::OffsetDateTime) -> Result<(), Error> { + let database = &self.database; + + // Query the database to see if we have any song within this music folder that has the same + // relative path. If yes, update its scanned at to the current time. + let song_id = if let Some((song_id, updated_at)) = self.set_scanned_at(entry).await? { + if entry.last_modified.is_some_and(|last_modified| last_modified < updated_at) { + // If its filesystem's last modified is sooner than its database's updated at, it + // means that we have the latest data, we can return the function. + return Ok(()); + } + Some(song_id) + } else { + None + }; + + let file = File::new(entry.format, self.filesystem.read(entry.path.to_path()).await?)?; + let dir_picture_id = picture::Picture::scan( + &self.database, + &self.filesystem, + &self.config.cover_art, + entry.path.parent().ok_or_else(|| Error::AbsoluteFilePathDoesNotHaveParentDirectory)?, + ) + .await?; + + let relative_path = self.relative_path(entry)?; + let relative_path = relative_path.as_str(); + if let Some((database_song_id, database_relative_path)) = + self.query_hash_size(&file.property).await? + { + if let Some(song_id) = song_id { + if song_id == database_song_id && relative_path == database_relative_path { + // Everything is the same but the song's last modified for some reason, update + // its updated at and return the function. + diesel::update(songs::table) + .filter(songs::id.eq(song_id)) + .set(songs::updated_at.eq(time::OffsetDateTime::now_utc())) + .execute(&mut database.get().await?) + .await?; + return Ok(()); + } + // Since `song_id` is queried only by music folder and relative path and there is a + // constraint `songs_album_id_file_hash_file_size_key`, other cases should be + // unreachable. + return Err(Error::DatabaseScanQueryInconsistent); + } + // We have one entry that is in the same music folder, same hash and size but + // different relative path (since song_id is none). We only need to update the relative + // path, set scanned at and return the function. + let album_id: Uuid = diesel::update(songs::table) + .filter(songs::id.eq(database_song_id)) + .set(( + songs::relative_path.eq(relative_path), + songs::scanned_at.eq(time::OffsetDateTime::now_utc()), + )) + .returning(songs::album_id) + .get_result(&mut database.get().await?) + .await?; + // We also need to set album cover_art_id since it might be added or removed after the + // previous scan. + diesel::update(albums::table) + .filter(albums::id.eq(album_id)) + .set(albums::cover_art_id.eq(dir_picture_id)) + .execute(&mut database.get().await?) + .await?; + + tracing::warn!( + old = ?database_relative_path, new = ?relative_path, "renamed duplication" + ); + return Ok(()); + } + + let audio = file.audio(self.config.lofty)?; + let information = audio.extract(&self.config.parsing)?; + + let song_id = information + .upsert( + database, + &self.config, + albums::Foreign { + music_folder_id: self.music_folder.id, + cover_art_id: dir_picture_id, + }, + relative_path, + song_id, + ) + .await?; + audio::Information::cleanup_one(database, started_at, song_id).await?; + + Ok(()) + } + + #[instrument( + skip(self), fields(music_folder_data = ?self.music_folder.data, started_at), ret, err + )] + pub async fn run(&self) -> Result<(), Error> { + let span = tracing::Span::current(); + let started_at = crate::time::now().await; + span.record("started_at", tracing::field::display(&started_at)); + + let (scan_handle, permit, rx) = self.init(); + let mut join_set = tokio::task::JoinSet::new(); + + while let Ok(entry) = rx.recv_async().await { + let permit = permit.clone().acquire_owned().await?; + let scan = self.clone().into_owned(); + join_set.spawn( + async move { + let _guard = permit; + scan.one(&entry, started_at).await + } + .instrument(span.clone()), + ); + } + + while let Some(result) = join_set.join_next().await { + result??; + } + scan_handle.await??; + + audio::Information::cleanup(&self.database, started_at).await?; + + self.database.upsert_config(&self.config.index).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use fake::Fake; + use rstest::rstest; + + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_simple_scan(#[future(awt)] mock: Mock, #[values(0, 10, 50)] n_song: usize) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().n_song(n_song).call().await; + + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + } + + // TODO: Make multiple scans work + #[allow(dead_code)] + async fn test_multiple_scan(mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().n_song(20).scan(false).call().await; + + let mut join_set = tokio::task::JoinSet::new(); + for _ in 0..5 { + let scanner = music_folder.scan().into_owned(); + join_set.spawn(async move { scanner.run().await.unwrap() }); + } + join_set.join_all().await; + + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + } + + mod filesystem { + use super::*; + + #[rstest] + #[tokio::test] + async fn test_overwrite(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + + music_folder.add_audio_filesystem().path("test").call().await; + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + + music_folder.add_audio_filesystem().path("test").call().await; + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + } + + #[rstest] + #[tokio::test] + async fn test_remove(#[future(awt)] mock: Mock, #[values(true, false)] same_dir: bool) { + let mut music_folder = mock.music_folder(0).await; + music_folder + .add_audio_filesystem::<&str>() + .n_song(10) + .depth(if same_dir { 0 } else { (1..3).fake() }) + .call() + .await; + music_folder.remove_audio_filesystem::<&str>().call().await; + + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + } + + #[rstest] + #[tokio::test] + async fn test_duplicate(#[future(awt)] mock: Mock, #[values(true, false)] same_dir: bool) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().depth(0).call().await; + let audio = music_folder.filesystem[0].clone(); + + music_folder + .add_audio_filesystem::<&str>() + .metadata(audio.information.metadata.clone()) + .format(audio.information.file.format) + .depth(if same_dir { 0 } else { (1..3).fake() }) + .call() + .await; + + let mut database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio.len(), 1); + let (database_path, database_audio) = database_audio.shift_remove_index(0).unwrap(); + + let (path, audio) = music_folder + .filesystem + .shift_remove_index(usize::from( + audio.relative_path != database_audio.relative_path, + )) + .unwrap(); + assert_eq!(database_path, path); + + let (database_audio, audio) = if same_dir { + (database_audio, audio) + } else { + (database_audio.with_dir_picture(None), audio.with_dir_picture(None)) + }; + assert_eq!(database_audio, audio); + } + + #[rstest] + #[tokio::test] + async fn test_move(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio_filesystem::<&str>().call().await; + let audio = music_folder.filesystem[0].clone(); + music_folder.remove_audio_filesystem::<&str>().index(0).call().await; + + music_folder + .add_audio_filesystem::<&str>() + .metadata(audio.information.metadata.clone()) + .format(audio.information.file.format) + .call() + .await; + + let database_audio = music_folder.query_filesystem().await; + assert_eq!(database_audio, music_folder.filesystem); + } + } + + #[rstest] + #[tokio::test] + async fn test_scan_dir_picture(#[future(awt)] mock: Mock) { + let mut music_folder = mock.music_folder(0).await; + music_folder + .add_audio_filesystem::<&str>() + .n_song(10) + .depth(0) + .recompute_dir_picture(false) + .call() + .await; + + // All pictures are the same. However, the picture will only be the same from the first + // file that has a picture so we have to filter out none before checking. + let dir_pictures: Vec<_> = music_folder + .filesystem + .values() + .filter_map(|information| information.dir_picture.clone()) + .collect(); + assert!(dir_pictures.windows(2).all(|window| window[0] == window[1])); + + // On the other hand, data queried from database should have all the same picture + // regardless if the very first file have a picture or not. So we use `map` instead of + // `filter_map` here. + let database_dir_pictures: Vec<_> = music_folder + .query_filesystem() + .await + .values() + .map(|information| information.dir_picture.clone()) + .collect(); + assert!(database_dir_pictures.windows(2).all(|window| window[0] == window[1])); + } +} diff --git a/nghe-backend/src/schema.rs b/nghe-backend/src/schema.rs new file mode 100644 index 000000000..3b4c71af4 --- /dev/null +++ b/nghe-backend/src/schema.rs @@ -0,0 +1,332 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + albums (id) { + id -> Uuid, + name -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + scanned_at -> Timestamptz, + year -> Nullable, + month -> Nullable, + day -> Nullable, + release_year -> Nullable, + release_month -> Nullable, + release_day -> Nullable, + original_release_year -> Nullable, + original_release_month -> Nullable, + original_release_day -> Nullable, + mbz_id -> Nullable, + ts -> Tsvector, + music_folder_id -> Uuid, + cover_art_id -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + artists (id) { + id -> Uuid, + name -> Text, + index -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + scanned_at -> Timestamptz, + mbz_id -> Nullable, + ts -> Tsvector, + lastfm_url -> Nullable, + lastfm_mbz_id -> Nullable, + lastfm_biography -> Nullable, + cover_art_id -> Nullable, + spotify_id -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + configs (key) { + key -> Text, + text -> Nullable, + byte -> Nullable, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + cover_arts (id) { + id -> Uuid, + format -> Text, + file_hash -> Int8, + file_size -> Int4, + source -> Nullable, + updated_at -> Timestamptz, + scanned_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + genres (id) { + id -> Uuid, + value -> Text, + upserted_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + lyrics (song_id, description, language, external) { + song_id -> Uuid, + description -> Text, + language -> Text, + line_values -> Array>, + line_starts -> Nullable>>, + lyric_hash -> Int8, + lyric_size -> Int4, + external -> Bool, + updated_at -> Timestamptz, + scanned_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + music_folders (id) { + id -> Uuid, + path -> Text, + scanned_at -> Timestamptz, + name -> Text, + updated_at -> Timestamptz, + fs_type -> Int2, + created_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + playbacks (user_id, song_id) { + user_id -> Uuid, + song_id -> Uuid, + count -> Int4, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + playlists (id) { + id -> Uuid, + name -> Text, + comment -> Nullable, + public -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + playlists_songs (playlist_id, song_id) { + playlist_id -> Uuid, + song_id -> Uuid, + created_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + playlists_users (playlist_id, user_id) { + playlist_id -> Uuid, + user_id -> Uuid, + access_level -> Int2, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + scans (started_at, music_folder_id) { + started_at -> Timestamptz, + is_scanning -> Bool, + finished_at -> Nullable, + music_folder_id -> Uuid, + scanned_song_count -> Int8, + upserted_song_count -> Int8, + deleted_song_count -> Int8, + deleted_album_count -> Int8, + deleted_artist_count -> Int8, + deleted_genre_count -> Int8, + scan_error_count -> Int8, + unrecoverable -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + songs (id) { + id -> Uuid, + title -> Text, + album_id -> Uuid, + track_number -> Nullable, + track_total -> Nullable, + disc_number -> Nullable, + disc_total -> Nullable, + year -> Nullable, + month -> Nullable, + day -> Nullable, + release_year -> Nullable, + release_month -> Nullable, + release_day -> Nullable, + original_release_year -> Nullable, + original_release_month -> Nullable, + original_release_day -> Nullable, + languages -> Array>, + format -> Text, + duration -> Float4, + bitrate -> Int4, + sample_rate -> Int4, + channel_count -> Int2, + relative_path -> Text, + file_hash -> Int8, + file_size -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + scanned_at -> Timestamptz, + cover_art_id -> Nullable, + mbz_id -> Nullable, + ts -> Tsvector, + bit_depth -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + songs_album_artists (song_id, album_artist_id) { + song_id -> Uuid, + album_artist_id -> Uuid, + upserted_at -> Timestamptz, + compilation -> Bool, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + songs_artists (song_id, artist_id) { + song_id -> Uuid, + artist_id -> Uuid, + upserted_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + songs_genres (song_id, genre_id) { + song_id -> Uuid, + genre_id -> Uuid, + upserted_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + user_music_folder_permissions (user_id, music_folder_id) { + user_id -> Uuid, + music_folder_id -> Uuid, + } +} + +diesel::table! { + use diesel::sql_types::*; + use diesel_full_text_search::*; + + users (id) { + id -> Uuid, + username -> Text, + password -> Bytea, + email -> Text, + admin_role -> Bool, + stream_role -> Bool, + download_role -> Bool, + share_role -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::joinable!(albums -> cover_arts (cover_art_id)); +diesel::joinable!(albums -> music_folders (music_folder_id)); +diesel::joinable!(artists -> cover_arts (cover_art_id)); +diesel::joinable!(lyrics -> songs (song_id)); +diesel::joinable!(playbacks -> songs (song_id)); +diesel::joinable!(playbacks -> users (user_id)); +diesel::joinable!(playlists_songs -> playlists (playlist_id)); +diesel::joinable!(playlists_songs -> songs (song_id)); +diesel::joinable!(playlists_users -> playlists (playlist_id)); +diesel::joinable!(playlists_users -> users (user_id)); +diesel::joinable!(scans -> music_folders (music_folder_id)); +diesel::joinable!(songs -> albums (album_id)); +diesel::joinable!(songs -> cover_arts (cover_art_id)); +diesel::joinable!(songs_album_artists -> artists (album_artist_id)); +diesel::joinable!(songs_album_artists -> songs (song_id)); +diesel::joinable!(songs_artists -> artists (artist_id)); +diesel::joinable!(songs_artists -> songs (song_id)); +diesel::joinable!(songs_genres -> genres (genre_id)); +diesel::joinable!(songs_genres -> songs (song_id)); +diesel::joinable!(user_music_folder_permissions -> music_folders (music_folder_id)); +diesel::joinable!(user_music_folder_permissions -> users (user_id)); + +diesel::allow_tables_to_appear_in_same_query!( + albums, + artists, + configs, + cover_arts, + genres, + lyrics, + music_folders, + playbacks, + playlists, + playlists_songs, + playlists_users, + scans, + songs, + songs_album_artists, + songs_artists, + songs_genres, + user_music_folder_permissions, + users, +); diff --git a/nghe-backend/src/sync.rs b/nghe-backend/src/sync.rs new file mode 100644 index 000000000..e43bc37ec --- /dev/null +++ b/nghe-backend/src/sync.rs @@ -0,0 +1,5 @@ +use loole::{Receiver, Sender}; + +pub fn channel(size: Option) -> (Sender, Receiver) { + if let Some(size) = size { loole::bounded(size) } else { loole::unbounded() } +} diff --git a/nghe-backend/src/test/assets/mod.rs b/nghe-backend/src/test/assets/mod.rs new file mode 100644 index 000000000..cca346fe0 --- /dev/null +++ b/nghe-backend/src/test/assets/mod.rs @@ -0,0 +1,12 @@ +use typed_path::Utf8TypedPathBuf; + +use crate::file::audio; +use crate::filesystem::path; + +pub fn dir() -> Utf8TypedPathBuf { + path::Local::from_str(&env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("assets").join("test") +} + +pub fn path(format: audio::Format) -> Utf8TypedPathBuf { + dir().join("sample").with_extension(format.as_ref()) +} diff --git a/nghe-backend/src/test/database/mod.rs b/nghe-backend/src/test/database/mod.rs new file mode 100644 index 000000000..61a9740b9 --- /dev/null +++ b/nghe-backend/src/test/database/mod.rs @@ -0,0 +1,70 @@ +use concat_string::concat_string; +use diesel_async::{AsyncConnection, AsyncPgConnection}; +use url::Url; +use uuid::Uuid; + +use crate::database::Database; +use crate::{config, migration}; + +pub struct Mock { + name: String, + url: String, + database: Database, +} + +impl Mock { + pub async fn new() -> Self { + let url = std::env::var("DATABASE_URL").unwrap(); + + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .with_test_writer() + .try_init(); + + let name = Uuid::new_v4().to_string(); + let mut mock_url = Url::parse(&url).unwrap(); + mock_url.set_path(&name); + + let mut root_conn = AsyncPgConnection::establish(&url).await.unwrap(); + diesel_async::RunQueryDsl::execute( + diesel::sql_query(concat_string!("CREATE DATABASE \"", name, "\";")), + &mut root_conn, + ) + .await + .unwrap(); + + let mock_url = mock_url.to_string(); + migration::run(&mock_url).await; + + Self { + name, + url, + database: Database::new(&config::Database { url: mock_url, key: rand::random() }), + } + } + + pub fn database(&self) -> &Database { + &self.database + } +} + +#[cfg(not(any(target_env = "musl", all(target_arch = "aarch64", target_os = "linux"))))] +impl Drop for Mock { + fn drop(&mut self) { + use diesel::pg::PgConnection; + use diesel::Connection; + + let raw_statement = + concat_string!("DROP DATABASE IF EXISTS \"", &self.name, "\" WITH (FORCE);"); + if let Err::<_, color_eyre::Report>(e) = try { + let mut conn = PgConnection::establish(&self.url)?; + diesel::RunQueryDsl::execute(diesel::sql_query(&raw_statement), &mut conn)?; + } { + println!("Could not drop temporary database because of {:?}", &e); + println!("Please drop the database manually with '{}'", &raw_statement); + } + } +} diff --git a/nghe-backend/src/test/file/audio/dump/flac.rs b/nghe-backend/src/test/file/audio/dump/flac.rs new file mode 100644 index 000000000..76021f0ad --- /dev/null +++ b/nghe-backend/src/test/file/audio/dump/flac.rs @@ -0,0 +1,50 @@ +use lofty::flac::FlacFile; +use lofty::ogg::OggPictureStorage as _; + +use super::Metadata; +use crate::config; +use crate::file::audio::{Album, Artists, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; + +impl Metadata for FlacFile { + fn dump_song(&mut self, config: &config::Parsing, song: NameDateMbz<'_>) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_song(config, song); + self + } + + fn dump_album(&mut self, config: &config::Parsing, album: Album<'_>) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_album(config, album); + self + } + + fn dump_artists(&mut self, config: &config::Parsing, artists: Artists<'_>) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_artists(config, artists); + self + } + + fn dump_track_disc(&mut self, config: &config::Parsing, track_disc: TrackDisc) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_track_disc(config, track_disc); + self + } + + fn dump_languages( + &mut self, + config: &config::Parsing, + languages: Vec, + ) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_languages(config, languages); + self + } + + fn dump_genres(&mut self, config: &config::Parsing, genres: Genres<'_>) -> &mut Self { + self.vorbis_comments_mut().unwrap().dump_genres(config, genres); + self + } + + fn dump_picture(&mut self, picture: Option>) -> &mut Self { + if let Some(picture) = picture { + self.insert_picture(picture.into(), None).unwrap(); + } + self + } +} diff --git a/nghe-backend/src/test/file/audio/dump/mod.rs b/nghe-backend/src/test/file/audio/dump/mod.rs new file mode 100644 index 000000000..d8a1f895d --- /dev/null +++ b/nghe-backend/src/test/file/audio/dump/mod.rs @@ -0,0 +1,99 @@ +mod flac; +mod tag; + +use isolang::Language; + +use crate::config; +use crate::file::audio::{self, Album, Artists, File, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; + +pub trait Metadata { + fn dump_song(&mut self, config: &config::Parsing, song: NameDateMbz<'_>) -> &mut Self; + fn dump_album(&mut self, config: &config::Parsing, album: Album<'_>) -> &mut Self; + fn dump_artists(&mut self, config: &config::Parsing, artists: Artists<'_>) -> &mut Self; + fn dump_track_disc(&mut self, config: &config::Parsing, track_disc: TrackDisc) -> &mut Self; + fn dump_languages(&mut self, config: &config::Parsing, languages: Vec) -> &mut Self; + fn dump_genres(&mut self, config: &config::Parsing, genres: Genres<'_>) -> &mut Self; + fn dump_picture(&mut self, picture: Option>) -> &mut Self; + + fn dump_metadata( + &mut self, + config: &config::Parsing, + metadata: audio::Metadata<'_>, + ) -> &mut Self { + let audio::Metadata { song, album, artists, genres, picture } = metadata; + let audio::Song { main, track_disc, languages } = song; + self.dump_song(config, main) + .dump_album(config, album) + .dump_artists(config, artists) + .dump_track_disc(config, track_disc) + .dump_languages(config, languages) + .dump_genres(config, genres) + .dump_picture(picture) + } +} + +impl Metadata for File { + fn dump_song(&mut self, config: &config::Parsing, song: NameDateMbz<'_>) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_song(config, song); + } + } + self + } + + fn dump_album(&mut self, config: &config::Parsing, album: Album<'_>) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_album(config, album); + } + } + self + } + + fn dump_artists(&mut self, config: &config::Parsing, artists: Artists<'_>) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_artists(config, artists); + } + } + self + } + + fn dump_track_disc(&mut self, config: &config::Parsing, track_disc: TrackDisc) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_track_disc(config, track_disc); + } + } + self + } + + fn dump_languages(&mut self, config: &config::Parsing, languages: Vec) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_languages(config, languages); + } + } + self + } + + fn dump_genres(&mut self, config: &config::Parsing, genres: Genres<'_>) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_genres(config, genres); + } + } + self + } + + fn dump_picture(&mut self, picture: Option>) -> &mut Self { + match self { + File::Flac { audio, .. } => { + audio.dump_picture(picture); + } + } + self + } +} diff --git a/nghe-backend/src/test/file/audio/dump/tag/mod.rs b/nghe-backend/src/test/file/audio/dump/tag/mod.rs new file mode 100644 index 000000000..f425370c6 --- /dev/null +++ b/nghe-backend/src/test/file/audio/dump/tag/mod.rs @@ -0,0 +1 @@ +mod vorbis_comments; diff --git a/nghe-backend/src/test/file/audio/dump/tag/vorbis_comments.rs b/nghe-backend/src/test/file/audio/dump/tag/vorbis_comments.rs new file mode 100644 index 000000000..8d0a6d154 --- /dev/null +++ b/nghe-backend/src/test/file/audio/dump/tag/vorbis_comments.rs @@ -0,0 +1,117 @@ +use indexmap::IndexSet; +use isolang::Language; +use lofty::ogg::{OggPictureStorage, VorbisComments}; +use uuid::Uuid; + +use crate::config; +use crate::file::audio::position::Position; +use crate::file::audio::{Album, Artist, Artists, Date, Genres, NameDateMbz, TrackDisc}; +use crate::file::picture::Picture; +use crate::test::file::audio::dump; + +impl Date { + fn dump_vorbis_comments(self, tag: &mut VorbisComments, key: Option<&str>) { + if let Some(key) = key + && self.is_some() + { + tag.push(key.to_string(), self.to_string()); + } + } +} + +impl NameDateMbz<'_> { + fn dump_vorbis_comments( + self, + tag: &mut VorbisComments, + config: &config::parsing::vorbis_comments::Common, + ) { + let Self { name, date, release_date, original_release_date, mbz_id } = self; + tag.push(config.name.clone(), name.into_owned()); + date.dump_vorbis_comments(tag, config.date.as_deref()); + release_date.dump_vorbis_comments(tag, config.release_date.as_deref()); + original_release_date.dump_vorbis_comments(tag, config.original_release_date.as_deref()); + if let Some(mbz_id) = mbz_id { + tag.push(config.mbz_id.clone(), mbz_id.to_string()); + } + } +} + +impl Artist<'_> { + fn dump_vorbis_comments( + artists: IndexSet, + tag: &mut VorbisComments, + config: &config::parsing::vorbis_comments::Artist, + ) { + for artist in artists { + tag.push(config.name.clone(), artist.name.into_owned()); + tag.push(config.mbz_id.clone(), artist.mbz_id.unwrap_or(Uuid::nil()).to_string()); + } + } +} + +impl Position { + fn dump_vorbis_comments(self, tag: &mut VorbisComments, number_key: &str, total_key: &str) { + if let Some(number) = self.number { + tag.push(number_key.to_owned(), number.to_string()); + } + if let Some(total) = self.total { + tag.push(total_key.to_owned(), total.to_string()); + } + } +} + +impl dump::Metadata for VorbisComments { + fn dump_song(&mut self, config: &config::Parsing, song: NameDateMbz<'_>) -> &mut Self { + song.dump_vorbis_comments(self, &config.vorbis_comments.song); + self + } + + fn dump_album(&mut self, config: &config::Parsing, album: Album<'_>) -> &mut Self { + album.dump_vorbis_comments(self, &config.vorbis_comments.album); + self + } + + fn dump_artists(&mut self, config: &config::Parsing, artists: Artists<'_>) -> &mut Self { + Artist::dump_vorbis_comments(artists.song, self, &config.vorbis_comments.artists.song); + Artist::dump_vorbis_comments(artists.album, self, &config.vorbis_comments.artists.album); + if artists.compilation { + self.push(config.vorbis_comments.compilation.clone(), "1".to_string()); + } + self + } + + fn dump_track_disc(&mut self, config: &config::Parsing, track_disc: TrackDisc) -> &mut Self { + track_disc.track.dump_vorbis_comments( + self, + &config.vorbis_comments.track_disc.track_number, + &config.vorbis_comments.track_disc.track_total, + ); + track_disc.disc.dump_vorbis_comments( + self, + &config.vorbis_comments.track_disc.disc_number, + &config.vorbis_comments.track_disc.disc_total, + ); + self + } + + fn dump_languages(&mut self, config: &config::Parsing, languages: Vec) -> &mut Self { + for language in languages { + self.push(config.vorbis_comments.languages.clone(), language.to_string()); + } + self + } + + fn dump_genres(&mut self, config: &config::Parsing, genres: Genres<'_>) -> &mut Self { + for genre in genres.value { + self.push(config.vorbis_comments.genres.clone(), genre.value.into_owned()); + } + self + } + + fn dump_picture(&mut self, picture: Option>) -> &mut Self { + if let Some(picture) = picture { + self.insert_picture(picture.into(), None).unwrap(); + } + self + } +} diff --git a/nghe-backend/src/test/file/audio/mod.rs b/nghe-backend/src/test/file/audio/mod.rs new file mode 100644 index 000000000..88f425104 --- /dev/null +++ b/nghe-backend/src/test/file/audio/mod.rs @@ -0,0 +1 @@ +pub mod dump; diff --git a/nghe-backend/src/test/file/mod.rs b/nghe-backend/src/test/file/mod.rs new file mode 100644 index 000000000..fa3e4e468 --- /dev/null +++ b/nghe-backend/src/test/file/mod.rs @@ -0,0 +1 @@ +pub mod audio; diff --git a/nghe-backend/src/test/filesystem/common.rs b/nghe-backend/src/test/filesystem/common.rs new file mode 100644 index 000000000..71cc7acd6 --- /dev/null +++ b/nghe-backend/src/test/filesystem/common.rs @@ -0,0 +1,125 @@ +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; + +use crate::file::{self, audio}; +use crate::http::binary; +use crate::{filesystem, Error}; + +#[derive(Debug)] +pub enum Impl<'fs> { + Local(&'fs super::local::Mock), + S3(&'fs super::s3::Mock), +} + +impl Impl<'_> { + pub fn path(&self) -> filesystem::path::Builder { + self.main().path() + } + + pub fn fake_path(&self, depth: usize) -> Utf8TypedPathBuf { + fake::vec![String; depth + 1] + .into_iter() + .fold(self.path().empty(), |path, component| path.join(component)) + } +} + +pub trait Trait: filesystem::Trait { + fn prefix(&self) -> Utf8TypedPath<'_>; + fn main(&self) -> filesystem::Impl<'_>; + + fn absolutize(&self, path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { + if path.is_absolute() { path.to_path_buf() } else { self.prefix().join(path) } + } + + async fn create_dir(&self, path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf; + async fn write(&self, path: Utf8TypedPath<'_>, data: &[u8]); + async fn delete(&self, path: Utf8TypedPath<'_>); +} + +impl filesystem::Trait for Impl<'_> { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + match self { + Impl::Local(filesystem) => filesystem.check_folder(path).await, + Impl::S3(filesystem) => filesystem.check_folder(path).await, + } + } + + async fn scan_folder( + &self, + sender: filesystem::entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + match self { + Impl::Local(filesystem) => filesystem.scan_folder(sender, prefix).await, + Impl::S3(filesystem) => filesystem.scan_folder(sender, prefix).await, + } + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + match self { + Impl::Local(filesystem) => filesystem.exists(path).await, + Impl::S3(filesystem) => filesystem.exists(path).await, + } + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + match self { + Impl::Local(filesystem) => filesystem.read(path).await, + Impl::S3(filesystem) => filesystem.read(path).await, + } + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + match self { + Impl::Local(filesystem) => filesystem.read_to_binary(source, offset).await, + Impl::S3(filesystem) => filesystem.read_to_binary(source, offset).await, + } + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + match self { + Impl::Local(filesystem) => filesystem.transcode_input(path).await, + Impl::S3(filesystem) => filesystem.transcode_input(path).await, + } + } +} + +impl Trait for Impl<'_> { + fn prefix(&self) -> Utf8TypedPath<'_> { + match self { + Impl::Local(filesystem) => filesystem.prefix(), + Impl::S3(filesystem) => filesystem.prefix(), + } + } + + fn main(&self) -> filesystem::Impl<'_> { + match self { + Impl::Local(filesystem) => filesystem.main(), + Impl::S3(filesystem) => filesystem.main(), + } + } + + async fn create_dir(&self, path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { + match self { + Impl::Local(filesystem) => filesystem.create_dir(path).await, + Impl::S3(filesystem) => filesystem.create_dir(path).await, + } + } + + async fn write(&self, path: Utf8TypedPath<'_>, data: &[u8]) { + match self { + Impl::Local(filesystem) => filesystem.write(path, data).await, + Impl::S3(filesystem) => filesystem.write(path, data).await, + } + } + + async fn delete(&self, path: Utf8TypedPath<'_>) { + match self { + Impl::Local(filesystem) => filesystem.delete(path).await, + Impl::S3(filesystem) => filesystem.delete(path).await, + } + } +} diff --git a/nghe-backend/src/test/filesystem/local.rs b/nghe-backend/src/test/filesystem/local.rs new file mode 100644 index 000000000..f340b77ad --- /dev/null +++ b/nghe-backend/src/test/filesystem/local.rs @@ -0,0 +1,89 @@ +use std::borrow::Cow; + +use nghe_api::constant; +use tempfile::{Builder, TempDir}; +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; + +use crate::file::{self, audio}; +use crate::filesystem::{self, local}; +use crate::http::binary; +use crate::Error; + +#[derive(Debug)] +pub struct Mock { + root: TempDir, + filesystem: local::Filesystem, +} + +impl Mock { + pub fn new(filesystem: local::Filesystem) -> Self { + Self { + root: Builder::new() + .prefix(&const_format::concatc!(constant::SERVER_NAME, ".")) + .tempdir() + .unwrap(), + filesystem, + } + } +} + +impl filesystem::Trait for Mock { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + self.filesystem.check_folder(path).await + } + + async fn scan_folder( + &self, + sender: filesystem::entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + self.filesystem.scan_folder(sender, prefix).await + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + self.filesystem.exists(path).await + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + self.filesystem.read(path).await + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + self.filesystem.read_to_binary(source, offset).await + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + self.filesystem.transcode_input(path).await + } +} + +impl super::Trait for Mock { + fn prefix(&self) -> Utf8TypedPath<'_> { + self.root.path().to_str().unwrap().into() + } + + fn main(&self) -> filesystem::Impl<'_> { + filesystem::Impl::Local(Cow::Borrowed(&self.filesystem)) + } + + async fn create_dir(&self, path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { + let path = self.absolutize(path); + tokio::fs::create_dir_all(path.as_str()).await.unwrap(); + path + } + + async fn write(&self, path: Utf8TypedPath<'_>, data: &[u8]) { + let path = self.absolutize(path); + self.create_dir(path.parent().unwrap()).await; + tokio::fs::write(path.as_str(), data).await.unwrap(); + } + + async fn delete(&self, path: Utf8TypedPath<'_>) { + let path = self.absolutize(path); + tokio::fs::remove_file(path.as_str()).await.unwrap(); + } +} diff --git a/nghe-backend/src/test/filesystem/mod.rs b/nghe-backend/src/test/filesystem/mod.rs new file mode 100644 index 000000000..e53522c07 --- /dev/null +++ b/nghe-backend/src/test/filesystem/mod.rs @@ -0,0 +1,42 @@ +mod common; +mod local; +mod s3; + +pub use common::{Impl, Trait}; +use nghe_api::common::filesystem; +use typed_path::{TryAsRef as _, Utf8NativePath, Utf8NativePathBuf}; + +use crate::filesystem::Filesystem; + +pub struct Mock { + filesystem: Filesystem, + local: local::Mock, + s3: s3::Mock, +} + +impl Mock { + pub async fn new(prefix: Option<&str>, config: &super::Config) -> Self { + let filesystem = Filesystem::new(&config.filesystem.tls, &config.filesystem.s3).await; + let local = local::Mock::new(filesystem.local()); + let s3 = s3::Mock::new(prefix, filesystem.s3()).await; + + Self { filesystem, local, s3 } + } + + pub fn filesystem(&self) -> &Filesystem { + &self.filesystem + } + + pub fn to_impl(&self, ty: filesystem::Type) -> Impl<'_> { + match ty { + filesystem::Type::Local => Impl::Local(&self.local), + filesystem::Type::S3 => Impl::S3(&self.s3), + } + } + + pub fn prefix(&self) -> Utf8NativePathBuf { + let prefix = self.local.prefix(); + let prefix: &Utf8NativePath = prefix.try_as_ref().unwrap(); + prefix.into() + } +} diff --git a/nghe-backend/src/test/filesystem/s3.rs b/nghe-backend/src/test/filesystem/s3.rs new file mode 100644 index 000000000..4f4559b44 --- /dev/null +++ b/nghe-backend/src/test/filesystem/s3.rs @@ -0,0 +1,112 @@ +use std::borrow::Cow; + +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::operation::create_bucket::CreateBucketError; +use aws_sdk_s3::Client; +use concat_string::concat_string; +use fake::{Fake, Faker}; +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; + +use crate::file::{self, audio}; +use crate::filesystem::{self, path, s3}; +use crate::http::binary; +use crate::Error; + +#[derive(Debug)] +pub struct Mock { + bucket: Utf8TypedPathBuf, + filesystem: s3::Filesystem, +} + +impl Mock { + pub async fn new(prefix: Option<&str>, filesystem: s3::Filesystem) -> Self { + let bucket = + prefix.map_or_else(|| Faker.fake::().to_lowercase().into(), Cow::Borrowed); + + let result = filesystem.client().create_bucket().bucket(bucket.clone()).send().await; + if result.is_err() { + if let Err(SdkError::ServiceError(err)) = + filesystem.client().create_bucket().bucket(bucket.clone()).send().await + && let CreateBucketError::BucketAlreadyOwnedByYou(_) = err.into_err() + { + } else { + panic!("Could not create bucket {bucket}") + } + } + + let bucket = path::S3::from_string(concat_string!("/", bucket)); + assert!(bucket.is_absolute()); + Self { bucket, filesystem } + } + + pub fn client(&self) -> &Client { + self.filesystem.client() + } +} + +impl filesystem::Trait for Mock { + async fn check_folder(&self, path: Utf8TypedPath<'_>) -> Result<(), Error> { + self.filesystem.check_folder(path).await + } + + async fn scan_folder( + &self, + sender: filesystem::entry::Sender, + prefix: Utf8TypedPath<'_>, + ) -> Result<(), Error> { + self.filesystem.scan_folder(sender, prefix).await + } + + async fn exists(&self, path: Utf8TypedPath<'_>) -> Result { + self.filesystem.exists(path).await + } + + async fn read(&self, path: Utf8TypedPath<'_>) -> Result, Error> { + self.filesystem.read(path).await + } + + async fn read_to_binary( + &self, + source: &binary::Source>, + offset: Option, + ) -> Result { + self.filesystem.read_to_binary(source, offset).await + } + + async fn transcode_input(&self, path: Utf8TypedPath<'_>) -> Result { + self.filesystem.transcode_input(path).await + } +} + +impl super::Trait for Mock { + fn prefix(&self) -> Utf8TypedPath<'_> { + self.bucket.to_path() + } + + fn main(&self) -> filesystem::Impl<'_> { + filesystem::Impl::S3(Cow::Borrowed(&self.filesystem)) + } + + async fn create_dir(&self, path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { + self.prefix().join(path) + } + + async fn write(&self, path: Utf8TypedPath<'_>, data: &[u8]) { + let path = self.absolutize(path); + let s3::Path { bucket, key } = s3::Filesystem::split(path.to_path()).unwrap(); + self.client() + .put_object() + .bucket(bucket) + .key(key) + .body(aws_sdk_s3::primitives::ByteStream::from(data.to_vec())) + .send() + .await + .unwrap(); + } + + async fn delete(&self, path: Utf8TypedPath<'_>) { + let path = self.absolutize(path); + let s3::Path { bucket, key } = s3::Filesystem::split(path.to_path()).unwrap(); + self.client().delete_object().bucket(bucket).key(key).send().await.unwrap(); + } +} diff --git a/nghe-backend/src/test/mock_impl/information.rs b/nghe-backend/src/test/mock_impl/information.rs new file mode 100644 index 000000000..2023d6e3a --- /dev/null +++ b/nghe-backend/src/test/mock_impl/information.rs @@ -0,0 +1,200 @@ +use std::borrow::Cow; +use std::io::{Cursor, Write}; + +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +use fake::{Fake, Faker}; +use uuid::Uuid; + +use super::music_folder; +use crate::file::{self, audio, picture}; +use crate::orm::{albums, songs}; +use crate::test::assets; +use crate::test::file::audio::dump::Metadata as _; +use crate::test::filesystem::Trait as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mock<'info, 'path> { + pub information: audio::Information<'info>, + pub dir_picture: Option>, + pub relative_path: Cow<'path, str>, +} + +#[bon::bon] +impl Mock<'static, 'static> { + pub async fn query_upsert(mock: &super::Mock, id: Uuid) -> songs::Upsert<'static> { + songs::table + .filter(songs::id.eq(id)) + .select(songs::Upsert::as_select()) + .get_result(&mut mock.get().await) + .await + .unwrap() + } + + pub async fn query_data(mock: &super::Mock, id: Uuid) -> songs::Data<'static> { + Self::query_upsert(mock, id).await.data + } + + pub async fn query(mock: &super::Mock, id: Uuid) -> Self { + let upsert = Self::query_upsert(mock, id).await; + let album_id = upsert.foreign.album_id; + let album = audio::Album::query_upsert(mock, upsert.foreign.album_id).await; + let artists = audio::Artists::query(mock, id).await; + let genres = audio::Genres::query(mock, id).await; + let picture = picture::Picture::query_song(mock, id).await; + + let dir_picture = picture::Picture::query_album(mock, album_id).await; + + Self { + information: audio::Information { + metadata: audio::Metadata { + song: upsert.data.song.try_into().unwrap(), + album: album.data.try_into().unwrap(), + artists, + genres, + picture, + }, + property: upsert.data.property.try_into().unwrap(), + file: upsert.data.file.into(), + }, + dir_picture, + relative_path: upsert.relative_path, + } + } + + #[builder( + builder_type(name = "Builder", vis = "pub"), + state_mod(name = "builder", vis = "pub"), + derive(Clone) + )] + pub fn builder( + metadata: Option>, + song: Option>, + album: Option>, + artists: Option>, + genres: Option>, + picture: Option>>, + format: Option, + property: Option, + dir_picture: Option>>, + relative_path: Option>, + ) -> Self { + let metadata = metadata.unwrap_or_else(|| audio::Metadata { + song: song.unwrap_or_else(|| Faker.fake()), + album: album.unwrap_or_else(|| Faker.fake()), + artists: artists.unwrap_or_else(|| Faker.fake()), + genres: genres.unwrap_or_else(|| Faker.fake()), + picture: picture.unwrap_or_else(|| Faker.fake()), + }); + let file = + file::Property { format: format.unwrap_or_else(|| Faker.fake()), ..Faker.fake() }; + let property = property.unwrap_or_else(|| audio::Property::default(file.format)); + + let dir_picture = dir_picture.unwrap_or_else(|| Faker.fake()); + let relative_path = + relative_path.map_or_else(|| Faker.fake::().into(), std::convert::Into::into); + + Self { + information: audio::Information { metadata, property, file }, + dir_picture, + relative_path, + } + } +} + +impl Mock<'_, '_> { + pub async fn upsert( + &self, + music_folder: &music_folder::Mock<'_>, + song_id: impl Into>, + ) -> Uuid { + let database = music_folder.database(); + let dir_picture_id = if let Some(ref dir) = music_folder.config.cover_art.dir + && let Some(ref picture) = self.dir_picture + { + Some(picture.upsert(database, dir).await.unwrap()) + } else { + None + }; + + self.information + .upsert( + database, + &music_folder.config, + albums::Foreign { + music_folder_id: music_folder.id(), + cover_art_id: dir_picture_id, + }, + self.relative_path.as_str(), + song_id, + ) + .await + .unwrap() + } + + pub async fn upsert_mock( + &self, + mock: &super::Mock, + index: usize, + song_id: impl Into>, + ) -> Uuid { + let music_folder = mock.music_folder(index).await; + self.upsert(&music_folder, song_id).await + } + + pub async fn dump(self, music_folder: &music_folder::Mock<'_>) -> Self { + let path = music_folder.path().join(&self.relative_path); + let path = path.to_path(); + + let format = self.information.file.format; + let data = tokio::fs::read(assets::path(format).as_str()).await.unwrap(); + let mut asset = Cursor::new(data.clone()); + let mut file = + file::File::new(format, data).unwrap().audio(music_folder.config.lofty).unwrap(); + asset.set_position(0); + + file.clear() + .dump_metadata(&music_folder.config.parsing, self.information.metadata.clone()) + .save_to(&mut asset, music_folder.write_options()); + + asset.flush().unwrap(); + asset.set_position(0); + let data = asset.into_inner(); + + let filesystem = &music_folder.to_impl(); + filesystem.write(path, &data).await; + + let cover_art_config = &music_folder.config.cover_art; + let parent = path.parent().unwrap(); + let dir_picture = if let Some(picture) = + picture::Picture::scan_filesystem(filesystem, cover_art_config, parent).await + { + Some(picture) + } else if let Some(picture) = self.dir_picture { + let path = parent.join(picture.property.format.name()); + filesystem.write(path.to_path(), &picture.data).await; + Some(picture::Picture { source: Some(path.into_string().into()), ..picture }) + } else { + None + }; + + Self { + information: audio::Information { + file: file::Property::new(format, &data).unwrap(), + ..self.information + }, + dir_picture, + ..self + } + } + + pub fn with_dir_picture(self, dir_picture: Option>) -> Self { + Self { dir_picture, ..self } + } +} + +impl<'path> Mock<'_, 'path> { + pub fn with_relative_path(self, relative_path: Cow<'path, str>) -> Self { + Self { relative_path, ..self } + } +} diff --git a/nghe-backend/src/test/mock_impl/mod.rs b/nghe-backend/src/test/mock_impl/mod.rs new file mode 100644 index 000000000..d9395fd7c --- /dev/null +++ b/nghe-backend/src/test/mock_impl/mod.rs @@ -0,0 +1,190 @@ +#![allow(clippy::option_option)] + +mod information; +mod music_folder; +mod user; + +use diesel_async::pooled_connection::deadpool; +use diesel_async::AsyncPgConnection; +use educe::Educe; +use fake::{Fake, Faker}; +pub use information::Mock as Information; +use lofty::config::{ParseOptions, WriteOptions}; +use nghe_api::common; +use rstest::fixture; +use typed_path::Utf8NativePath; +use uuid::Uuid; + +use super::filesystem::Trait; +use super::{database, filesystem}; +use crate::database::Database; +use crate::file::audio; +use crate::filesystem::Filesystem; +use crate::orm::users; +use crate::scan::scanner; +use crate::{config, route}; + +#[derive(Debug, Educe)] +#[educe(Default)] +pub struct Config { + #[educe(Default(expression = config::filesystem::Filesystem::test()))] + pub filesystem: config::filesystem::Filesystem, + #[educe(Default(expression = config::Parsing::test()))] + pub parsing: config::Parsing, + pub index: config::Index, + pub transcode: config::Transcode, + pub cover_art: config::CoverArt, + + pub lofty_parse: ParseOptions, + pub lofty_write: WriteOptions, +} + +pub struct Mock { + pub config: Config, + + pub database: database::Mock, + pub filesystem: filesystem::Mock, +} + +impl Config { + fn with_prefix(self, prefix: impl AsRef + Copy) -> Self { + Self { + transcode: self.transcode.with_prefix(prefix), + cover_art: self.cover_art.with_prefix(prefix), + ..self + } + } + + pub fn scanner(&self) -> scanner::Config { + scanner::Config { + lofty: self.lofty_parse, + scan: self.filesystem.scan, + parsing: self.parsing.clone(), + index: self.index.clone(), + cover_art: self.cover_art.clone(), + } + } +} + +#[bon::bon] +impl Mock { + async fn new(prefix: Option<&str>, config: Config) -> Self { + let database = database::Mock::new().await; + let filesystem = filesystem::Mock::new(prefix, &config).await; + let config = config.with_prefix(&filesystem.prefix()); + + Self { config, database, filesystem } + } + + pub fn state(&self) -> &Database { + self.database() + } + + pub fn database(&self) -> &Database { + self.database.database() + } + + pub async fn get(&self) -> deadpool::Object { + self.database().get().await.unwrap() + } + + #[builder] + pub async fn add_user( + &self, + #[builder(default = users::Role { + admin: false, + stream: true, + download: true, + share: false + })] + role: users::Role, + #[builder(default = true)] allow: bool, + ) -> &Self { + route::user::create::handler( + self.database(), + route::user::create::Request { role: role.into(), allow, ..Faker.fake() }, + ) + .await + .unwrap(); + self + } + + pub async fn user(&self, index: usize) -> user::Mock<'_> { + user::Mock::new(self, index).await + } + + pub async fn user_id(&self, index: usize) -> Uuid { + self.user(index).await.id() + } + + pub fn filesystem(&self) -> &Filesystem { + self.filesystem.filesystem() + } + + pub fn to_impl(&self, ty: common::filesystem::Type) -> filesystem::Impl<'_> { + self.filesystem.to_impl(ty) + } + + #[builder] + pub async fn add_music_folder( + &self, + #[builder(default = Faker.fake::())] ty: common::filesystem::Type, + #[builder(default = true)] allow: bool, + ) -> Uuid { + let filesystem = self.to_impl(ty); + route::music_folder::add::handler( + self.database(), + self.filesystem(), + route::music_folder::add::Request { + ty, + allow, + path: filesystem + .create_dir(Faker.fake::().as_str().into()) + .await + .into_string(), + ..Faker.fake() + }, + ) + .await + .unwrap() + .music_folder_id + } + + pub async fn music_folder(&self, index: usize) -> music_folder::Mock<'_> { + music_folder::Mock::new(self, index).await + } + + pub async fn music_folder_id(&self, index: usize) -> Uuid { + self.music_folder(index).await.id() + } + + pub async fn add_audio_artist( + &self, + index: usize, + songs: impl IntoIterator>, + albums: impl IntoIterator>, + compilation: bool, + n_song: usize, + ) { + self.music_folder(index).await.add_audio_artist(songs, albums, compilation, n_song).await; + } +} + +#[fixture] +pub async fn mock( + #[default(1)] n_user: usize, + #[default(1)] n_music_folder: usize, + #[default(None)] prefix: Option<&str>, + #[default(Config::default())] config: Config, +) -> Mock { + let mock = Mock::new(prefix, config).await; + for _ in 0..n_user { + mock.add_user().call().await; + } + for _ in 0..n_music_folder { + mock.add_music_folder().ty(Faker.fake::()).call().await; + } + mock.database().upsert_config(&mock.config.index).await.unwrap(); + + mock +} diff --git a/nghe-backend/src/test/mock_impl/music_folder.rs b/nghe-backend/src/test/mock_impl/music_folder.rs new file mode 100644 index 000000000..0032e1c65 --- /dev/null +++ b/nghe-backend/src/test/mock_impl/music_folder.rs @@ -0,0 +1,319 @@ +#![allow(clippy::struct_field_names)] + +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +use fake::{Fake, Faker}; +use futures_lite::{stream, StreamExt}; +use indexmap::IndexMap; +use itertools::Itertools; +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; +use uuid::Uuid; + +use super::Information; +use crate::database::Database; +use crate::file::{audio, picture, File}; +use crate::filesystem::Trait as _; +use crate::orm::{albums, music_folders, songs}; +use crate::scan::scanner; +use crate::test::filesystem::{self, Trait as _}; + +pub struct Mock<'a> { + mock: &'a super::Mock, + music_folder: music_folders::MusicFolder<'static>, + pub filesystem: IndexMap>, + pub database: IndexMap>, + pub config: scanner::Config, +} + +#[bon::bon] +impl<'a> Mock<'a> { + pub async fn new(mock: &'a super::Mock, index: usize) -> Self { + Self { + mock, + music_folder: music_folders::table + .select(music_folders::MusicFolder::as_select()) + .order_by(music_folders::created_at) + .offset(index.try_into().unwrap()) + .first(&mut mock.get().await) + .await + .unwrap(), + filesystem: IndexMap::new(), + database: IndexMap::new(), + config: mock.config.scanner(), + } + } + + pub fn database(&self) -> &Database { + self.mock.database() + } + + pub fn write_options(&self) -> lofty::config::WriteOptions { + self.mock.config.lofty_write + } + + pub fn id(&self) -> Uuid { + self.music_folder.id + } + + pub fn path(&self) -> Utf8TypedPath<'_> { + self.path_str(&self.music_folder.data.path) + } + + pub fn path_str<'path>(&self, value: &'path (impl AsRef + Sized)) -> Utf8TypedPath<'path> { + self.to_impl().path().from_str(value) + } + + pub fn path_string(&self, value: impl Into) -> Utf8TypedPathBuf { + self.to_impl().path().from_string(value) + } + + pub fn absolute_path(&self, index: usize) -> Utf8TypedPathBuf { + self.path().join(self.filesystem.get_index(index).unwrap().0) + } + + pub fn absolutize(&self, path: impl AsRef) -> Utf8TypedPathBuf { + let path = self.path_str(&path); + let music_folder_path = self.path(); + + if path.is_absolute() { + if path.starts_with(music_folder_path) { + path.to_path_buf() + } else { + panic!("Path {path} does not start with music folder path {music_folder_path}") + } + } else { + music_folder_path.join(path) + } + } + + pub fn relativize<'b>(&self, path: &'b Utf8TypedPath<'b>) -> Utf8TypedPath<'b> { + if path.is_absolute() { path.strip_prefix(self.path()).unwrap() } else { *path } + } + + pub fn to_impl(&self) -> filesystem::Impl<'_> { + self.mock.to_impl(self.music_folder.data.ty.into()) + } + + #[builder] + pub async fn add_audio( + &mut self, + metadata: Option>, + song: Option>, + album: Option>, + artists: Option>, + genres: Option>, + picture: Option>>, + dir_picture: Option>>, + #[builder(default = 1)] n_song: usize, + ) -> &mut Self { + let builder = Information::builder() + .maybe_metadata(metadata) + .maybe_song(song) + .maybe_album(album) + .maybe_artists(artists) + .maybe_genres(genres) + .maybe_picture(picture) + .maybe_dir_picture(dir_picture); + + for _ in 0..n_song { + let information = builder.clone().build(); + let song_id = information.upsert(self, None).await; + self.database.insert(song_id, information); + } + + self + } + + #[builder] + pub async fn add_audio_filesystem( + &mut self, + path: Option>, + #[builder(default = (0..3).fake::())] depth: usize, + #[builder(default = Faker.fake::())] format: audio::Format, + metadata: Option>, + song: Option>, + album: Option>, + artists: Option>, + genres: Option>, + picture: Option>>, + dir_picture: Option>>, + #[builder(default = 1)] n_song: usize, + #[builder(default = true)] scan: bool, + #[builder(default = true)] recompute_dir_picture: bool, + ) -> &mut Self { + let builder = Information::builder() + .maybe_metadata(metadata) + .maybe_song(song) + .maybe_album(album) + .maybe_artists(artists) + .maybe_genres(genres) + .maybe_picture(picture) + .maybe_dir_picture(dir_picture); + + for _ in 0..n_song { + let relative_path = if let Some(ref path) = path { + assert_eq!(n_song, 1, "The same path is supplied for multiple audio"); + self.relativize(&self.path_str(path)).to_path_buf() + } else { + self.to_impl().fake_path(depth) + } + .with_extension(format.as_ref()); + + let information = builder + .clone() + .format(format) + .relative_path(relative_path.to_string().into()) + .build(); + let information = information.dump(self).await; + + self.filesystem.shift_remove(&relative_path); + self.filesystem.insert(relative_path.clone(), information); + } + + if scan { + self.scan().run().await.unwrap(); + } + + if recompute_dir_picture { + let group = self + .filesystem + .clone() + .into_iter() + .into_group_map_by(|value| value.0.parent().unwrap().to_path_buf()); + for (parent, files) in group { + let dir_picture = picture::Picture::scan_filesystem( + &self.to_impl(), + &self.config.cover_art, + self.path().join(parent).to_path(), + ) + .await; + for (path, information) in files { + let information = + Information { dir_picture: dir_picture.clone(), ..information }; + self.filesystem.insert(path, information); + } + } + } + + self + } + + pub async fn add_audio_artist( + &mut self, + songs: impl IntoIterator>, + albums: impl IntoIterator>, + compilation: bool, + n_song: usize, + ) { + self.add_audio() + .artists(audio::Artists { + song: songs.into_iter().collect(), + album: albums.into_iter().collect(), + compilation, + }) + .n_song(n_song) + .call() + .await; + } + + #[builder] + pub async fn remove_audio_filesystem( + &mut self, + path: Option>, + #[builder(default = 0)] index: usize, + #[builder(default = true)] scan: bool, + ) -> &mut Self { + if let Some(path) = path { + let absolute_path = self.absolutize(path); + let absolute_path = absolute_path.to_path(); + let relative_path = self.relativize(&absolute_path).to_path_buf(); + self.to_impl().delete(absolute_path).await; + self.filesystem.shift_remove(&relative_path); + } else if let Some((relative_path, _)) = self.filesystem.shift_remove_index(index) { + self.to_impl().delete(self.absolutize(relative_path).to_path()).await; + } + + if scan { + self.scan().run().await.unwrap(); + } + + self + } + + pub async fn file(&self, path: Utf8TypedPath<'_>, format: audio::Format) -> audio::File { + let path = self.absolutize(path).with_extension(format.as_ref()); + File::new(format, self.to_impl().read(path.to_path()).await.unwrap()) + .unwrap() + .audio(self.config.lofty) + .unwrap() + } + + pub fn scan(&self) -> scanner::Scanner<'_, '_, '_> { + scanner::Scanner::new_orm( + self.mock.database(), + self.mock.filesystem(), + self.config.clone(), + music_folders::MusicFolder { + id: self.music_folder.id, + data: music_folders::Data { + path: self.music_folder.data.path.as_str().into(), + ty: self.music_folder.data.ty, + }, + }, + ) + .unwrap() + } + + async fn optional_song_id_filesystem(&self, index: usize) -> Option { + let path = self.filesystem.get_index(index).unwrap().0.as_str(); + albums::table + .inner_join(songs::table) + .filter(albums::music_folder_id.eq(self.music_folder.id)) + .filter(songs::relative_path.eq(path)) + .select(songs::id) + .get_result(&mut self.mock.get().await) + .await + .optional() + .unwrap() + } + + pub fn song_id(&self, index: usize) -> Uuid { + *self.database.get_index(index).unwrap().0 + } + + pub async fn song_id_filesystem(&self, index: usize) -> Uuid { + self.optional_song_id_filesystem(index).await.unwrap() + } + + pub async fn query_filesystem( + &self, + ) -> IndexMap> { + let song_ids: Vec<_> = stream::iter(0..self.filesystem.len()) + .then(async |index| self.optional_song_id_filesystem(index).await) + .filter_map(std::convert::identity) + .collect() + .await; + stream::iter(song_ids) + .then(async |id| { + let mock = Information::query(self.mock, id).await; + (self.path_str(&mock.relative_path).to_path_buf(), mock) + }) + .collect() + .await + } +} + +mod duration { + use super::*; + use crate::orm::id3::duration::Trait; + use crate::Error; + + impl Trait for IndexMap> { + fn duration(&self) -> Result { + self.values() + .map(|information| information.information.property.duration) + .sum::() + .duration() + } + } +} diff --git a/nghe-backend/src/test/mock_impl/user.rs b/nghe-backend/src/test/mock_impl/user.rs new file mode 100644 index 000000000..fc550f802 --- /dev/null +++ b/nghe-backend/src/test/mock_impl/user.rs @@ -0,0 +1,50 @@ +use diesel::{QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +use fake::{Fake, Faker}; +use nghe_api::auth; +use o2o::o2o; +use uuid::Uuid; + +use crate::orm::users; + +// TODO: remove this after https://github.com/SoftbearStudios/bitcode/issues/27 +#[derive(o2o)] +#[ref_into(auth::Auth<'u, 's>)] +pub struct Auth { + #[into(~.as_str())] + pub username: String, + #[into(~.as_str())] + pub salt: String, + pub token: auth::Token, +} + +pub struct Mock<'a> { + mock: &'a super::Mock, + pub user: users::User<'static>, +} + +impl<'a> Mock<'a> { + pub async fn new(mock: &'a super::Mock, index: usize) -> Self { + Self { + mock, + user: users::table + .select(users::User::as_select()) + .order_by(users::created_at) + .offset(index.try_into().unwrap()) + .first(&mut mock.get().await) + .await + .unwrap(), + } + } + + pub fn id(&self) -> Uuid { + self.user.id + } + + pub fn auth(&self) -> Auth { + let users::Data { username, password, .. } = &self.user.data; + let salt: String = Faker.fake(); + let token = auth::Auth::tokenize(self.mock.database().decrypt(password).unwrap(), &salt); + Auth { username: username.to_string(), salt, token } + } +} diff --git a/nghe-backend/src/test/mod.rs b/nghe-backend/src/test/mod.rs new file mode 100644 index 000000000..e5b84dc9f --- /dev/null +++ b/nghe-backend/src/test/mod.rs @@ -0,0 +1,9 @@ +pub mod assets; +mod database; +pub mod file; +pub mod filesystem; +mod mock_impl; +pub mod route; +pub mod transcode; + +pub use mock_impl::{mock, Config, Information, Mock}; diff --git a/nghe-backend/src/test/route/mod.rs b/nghe-backend/src/test/route/mod.rs new file mode 100644 index 000000000..bcb4e6449 --- /dev/null +++ b/nghe-backend/src/test/route/mod.rs @@ -0,0 +1 @@ +pub mod permission; diff --git a/nghe-backend/src/test/route/permission.rs b/nghe-backend/src/test/route/permission.rs new file mode 100644 index 000000000..03b094cec --- /dev/null +++ b/nghe-backend/src/test/route/permission.rs @@ -0,0 +1,22 @@ +use diesel::QueryDsl; +use diesel_async::RunQueryDsl; + +use crate::orm::user_music_folder_permissions; +use crate::test::Mock; + +pub async fn reset(mock: &Mock) { + diesel::delete(user_music_folder_permissions::table) + .execute(&mut mock.get().await) + .await + .unwrap(); +} + +pub async fn count(mock: &Mock) -> usize { + user_music_folder_permissions::table + .count() + .get_result::(&mut mock.get().await) + .await + .unwrap() + .try_into() + .unwrap() +} diff --git a/nghe-backend/src/test/transcode.rs b/nghe-backend/src/test/transcode.rs new file mode 100644 index 000000000..1e239b8e4 --- /dev/null +++ b/nghe-backend/src/test/transcode.rs @@ -0,0 +1,42 @@ +use axum::http::{HeaderName, HeaderValue}; +use axum_extra::headers; +use strum::{EnumString, IntoStaticStr}; + +static TRANSCODE_STATUS: HeaderName = HeaderName::from_static("x-transcode-status"); + +#[derive(Debug, Clone, Copy, EnumString, IntoStaticStr, PartialEq, Eq, PartialOrd, Ord)] +#[strum(serialize_all = "lowercase")] +pub enum Status { + NoCache, + WithCache, + ServeCachedOutput, + UseCachedOutput, +} + +pub struct Header(pub Status); + +impl headers::Header for Header { + fn name() -> &'static HeaderName { + &TRANSCODE_STATUS + } + + fn decode<'i, I>(values: &mut I) -> Result + where + Self: Sized, + I: Iterator, + { + Ok(Self( + values + .next() + .ok_or_else(headers::Error::invalid)? + .to_str() + .map_err(|_| headers::Error::invalid())? + .parse() + .map_err(|_| headers::Error::invalid())?, + )) + } + + fn encode>(&self, values: &mut E) { + values.extend(std::iter::once(HeaderValue::from_static(self.0.into()))); + } +} diff --git a/nghe-backend/src/time/mod.rs b/nghe-backend/src/time/mod.rs new file mode 100644 index 000000000..22d490cd9 --- /dev/null +++ b/nghe-backend/src/time/mod.rs @@ -0,0 +1,8 @@ +use std::time::Duration; + +pub async fn now() -> time::OffsetDateTime { + let now = time::OffsetDateTime::now_utc(); + // Sleep one microsecond because that is the highest time precision postgresql can store. + tokio::time::sleep(Duration::from_micros(1)).await; + now +} diff --git a/nghe-backend/src/transcode/format.rs b/nghe-backend/src/transcode/format.rs new file mode 100644 index 000000000..366b0fda9 --- /dev/null +++ b/nghe-backend/src/transcode/format.rs @@ -0,0 +1,25 @@ +use axum_extra::headers::{CacheControl, ETag}; +use nghe_api::common::format; + +use crate::http::binary; +use crate::Error; + +impl binary::property::Trait for format::Transcode { + const SEEKABLE: bool = false; + + fn mime(&self) -> &'static str { + format::Trait::mime(self) + } + + fn size(&self) -> Option { + None + } + + fn etag(&self) -> Result, Error> { + Ok(None) + } + + fn cache_control() -> CacheControl { + CacheControl::new().with_no_cache() + } +} diff --git a/nghe-backend/src/transcode/mod.rs b/nghe-backend/src/transcode/mod.rs new file mode 100644 index 000000000..74edc36f1 --- /dev/null +++ b/nghe-backend/src/transcode/mod.rs @@ -0,0 +1,6 @@ +mod format; +mod sink; +mod transcoder; + +pub use sink::Sink; +pub use transcoder::Transcoder; diff --git a/nghe-backend/src/transcode/sink.rs b/nghe-backend/src/transcode/sink.rs new file mode 100644 index 000000000..670b41caf --- /dev/null +++ b/nghe-backend/src/transcode/sink.rs @@ -0,0 +1,125 @@ +use std::ffi::CStr; +use std::fmt::Debug; +use std::io::Write; + +use educe::Educe; +use fs4::fs_std::FileExt; +use loole::{Receiver, Sender}; +use nghe_api::common::format; +use rsmpeg::avformat::{AVIOContextContainer, AVIOContextCustom}; +use rsmpeg::avutil::AVMem; +use rsmpeg::ffi; +use tracing::instrument; +use typed_path::Utf8NativePath; + +use crate::{config, Error}; + +#[derive(Educe)] +#[educe(Debug)] +pub struct Sink { + #[educe(Debug(ignore))] + tx: Sender>, + buffer_size: usize, + format: format::Transcode, + file: Option, +} + +impl Sink { + pub async fn new( + config: &config::Transcode, + format: format::Transcode, + output: Option + Debug + Send + 'static>, + ) -> Result<(Self, Receiver>), Error> { + let (tx, rx) = crate::sync::channel(config.channel_size); + // It will fail in two cases: + // - The file already exists because of `create_new`. + // - The lock can not be acquired. In this case, another process is already writing to this + // file. + // In both cases, we could start transcoding without writing to a file. + let span = tracing::Span::current(); + let file = tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + output.map(Self::lock_write).transpose().ok().flatten() + }) + .await?; + Ok((Self { tx, buffer_size: config.buffer_size, format, file }, rx)) + } + + pub fn format(&self) -> &'static CStr { + // TODO: Use ffmpeg format code after https://github.com/larksuite/rsmpeg/pull/196 + match self.format { + format::Transcode::Aac => c"output.aac", + format::Transcode::Flac => c"output.flac", + format::Transcode::Mp3 => c"output.mp3", + format::Transcode::Opus => c"output.opus", + format::Transcode::Wav => c"output.wav", + format::Transcode::Wma => c"output.wma", + } + } + + #[instrument(err(level = "debug"))] + pub fn lock_write(path: impl AsRef + Debug) -> Result { + let file = std::fs::OpenOptions::new().write(true).create_new(true).open(path.as_ref())?; + file.try_lock_exclusive()?; + Ok(file) + } + + #[instrument(err(level = "debug"))] + pub fn lock_read(path: impl AsRef + Debug) -> Result { + let file = if cfg!(windows) { + // On Windows, the file must be open with write permissions to lock it. + std::fs::OpenOptions::new().read(true).write(true).open(path.as_ref())? + } else { + std::fs::OpenOptions::new().read(true).open(path.as_ref())? + }; + FileExt::try_lock_shared(&file)?; + Ok(file) + } + + fn write(&mut self, data: &[u8]) -> i32 { + let write_len = data.len().try_into().unwrap_or(ffi::AVERROR_BUG2); + + let send_result = self.tx.send(data.to_vec()); + let write_result = self.file.as_mut().map(|file| file.write_all(data)); + + tracing::trace!(?write_len, ?send_result, ?write_result); + + // We will keep continue writing in one of two cases below: + // - We can still send data to the receiver. We don't care if we can write or not + // (including the case where the file is none). + // - We can write to the file (this means the file must not be none). + if send_result.is_ok() || write_result.is_some_and(|result| result.is_ok()) { + write_len + } else { + ffi::AVERROR_OUTPUT_CHANGED + } + } +} + +impl From for AVIOContextContainer { + fn from(mut sink: Sink) -> Self { + AVIOContextContainer::Custom(AVIOContextCustom::alloc_context( + AVMem::new(sink.buffer_size), + true, + Vec::default(), + None, + Some(Box::new(move |_, data| sink.write(data))), + None, + )) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::test::transcode::Status; + + impl Sink { + pub fn status(&self, status: Status) -> Status { + match status { + Status::WithCache if self.file.is_none() => Status::NoCache, + _ => status, + } + } + } +} diff --git a/nghe-backend/src/transcode/transcoder.rs b/nghe-backend/src/transcode/transcoder.rs new file mode 100644 index 000000000..7b23d1ef1 --- /dev/null +++ b/nghe-backend/src/transcode/transcoder.rs @@ -0,0 +1,341 @@ +use std::borrow::Cow; +use std::ffi::{CStr, CString}; +use std::fmt::Debug; + +use concat_string::concat_string; +use rsmpeg::avcodec::{AVCodec, AVCodecContext}; +use rsmpeg::avfilter::{AVFilter, AVFilterContextMut, AVFilterGraph, AVFilterInOut}; +use rsmpeg::avformat::{AVFormatContextInput, AVFormatContextOutput}; +use rsmpeg::avutil::AVFrame; +use rsmpeg::error::RsmpegError; +use rsmpeg::{avutil, ffi, UnsafeDerefMut}; +use tracing::instrument; + +use super::Sink; +use crate::Error; + +struct Input { + context: AVFormatContextInput, + decoder: AVCodecContext, + index: i32, +} + +struct Output { + context: AVFormatContextOutput, + encoder: AVCodecContext, +} + +struct Graph { + filter: AVFilterGraph, + spec: Cow<'static, CStr>, +} + +struct Filter<'a> { + source: AVFilterContextMut<'a>, + sink: AVFilterContextMut<'a>, +} + +pub struct Transcoder { + input: Input, + output: Output, + graph: Graph, +} + +impl Input { + fn new(input: &CStr) -> Result { + let context = AVFormatContextInput::open(input, None, &mut None)?; + let (index, codec) = context + .find_best_stream(ffi::AVMEDIA_TYPE_AUDIO)? + .ok_or_else(|| Error::MediaAudioTrackMissing)?; + let stream = &context.streams()[index]; + + let mut decoder = AVCodecContext::new(&codec); + decoder.apply_codecpar(&stream.codecpar())?; + decoder.open(None)?; + decoder.set_pkt_timebase(stream.time_base); + decoder.set_bit_rate(context.bit_rate); + + Ok(Self { context, decoder, index: index.try_into()? }) + } +} + +impl Output { + fn new(sink: Sink, bitrate: u32, decoder: &AVCodecContext) -> Result { + let mut context = AVFormatContextOutput::create(sink.format(), Some(sink.into()))?; + + if cfg!(test) { + // Set bitexact for deterministic transcoding output. + unsafe { + context.deref_mut().flags |= ffi::AVFMT_FLAG_BITEXACT as i32; + } + } + + let codec = AVCodec::find_encoder(context.oformat().audio_codec) + .ok_or_else(|| Error::TranscodeOutputFormatNotSupported)?; + + // bit to kbit + let bitrate = bitrate * 1000; + // Opus sample rate will always be 48000Hz. + let sample_rate = + if codec.id == ffi::AV_CODEC_ID_OPUS { 48000 } else { decoder.sample_rate }; + + let mut encoder = AVCodecContext::new(&codec); + encoder.set_ch_layout(decoder.ch_layout); + encoder.set_sample_fmt( + codec.sample_fmts().ok_or_else(|| Error::TranscodeEncoderSampleFmtsMissing)?[0], + ); + encoder.set_sample_rate(sample_rate); + encoder.set_bit_rate(bitrate.into()); + encoder.set_time_base(avutil::ra(1, sample_rate)); + + // Some formats want stream headers to be separate. + if context.oformat().flags & ffi::AVFMT_GLOBALHEADER as i32 != 0 { + encoder.set_flags(encoder.flags | ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32); + } + + encoder.open(None)?; + { + let mut stream = context.new_stream(); + stream.set_codecpar(encoder.extract_codecpar()); + stream.set_time_base(encoder.time_base); + } + context.write_header(&mut None)?; + + Ok(Self { context, encoder }) + } + + fn encode(&mut self, frame: Option<&AVFrame>) -> Result<(), Error> { + self.encoder.send_frame(frame)?; + + loop { + let mut packet = match self.encoder.receive_packet() { + Err(RsmpegError::EncoderDrainError | RsmpegError::EncoderFlushedError) => { + return Ok(()); + } + result => result?, + }; + + packet.set_stream_index(0); + packet.rescale_ts(self.encoder.time_base, self.context.streams()[0].time_base); + self.context.interleaved_write_frame(&mut packet)?; + } + } + + fn flush(&mut self) -> Result<(), Error> { + if self.encoder.codec().capabilities & ffi::AV_CODEC_CAP_DELAY as i32 != 0 { + self.encode(None) + } else { + Ok(()) + } + } +} + +impl Graph { + fn new(decoder: &AVCodecContext, encoder: &AVCodecContext, offset: u32) -> Result { + let mut specs: Vec> = vec![]; + if offset > 0 { + specs.push(concat_string!("atrim=start=", offset.to_string()).into()); + } + if decoder.sample_rate != encoder.sample_rate { + specs.push("aresample=resampler=soxr".into()); + } + if encoder.frame_size > 0 { + specs.push( + concat_string!("asetnsamples=n=", encoder.frame_size.to_string(), ":p=0").into(), + ); + } + + let spec = + if specs.is_empty() { c"anull".into() } else { CString::new(specs.join(","))?.into() }; + + Ok(Self { filter: AVFilterGraph::new(), spec }) + } +} + +impl<'graph> Filter<'graph> { + pub fn new( + graph: &'graph Graph, + decoder: &AVCodecContext, + encoder: &AVCodecContext, + ) -> Result { + let source_ref = AVFilter::get_by_name(c"abuffer") + .ok_or_else(|| Error::TranscodeAVFilterMissing("abuffer"))?; + let sink_ref = AVFilter::get_by_name(c"abuffersink") + .ok_or_else(|| Error::TranscodeAVFilterMissing("abuffersink"))?; + + let source_arg = concat_string!( + "time_base=", + decoder.pkt_timebase.num.to_string(), + "/", + decoder.pkt_timebase.den.to_string(), + ":sample_rate=", + decoder.sample_rate.to_string(), + ":sample_fmt=", + avutil::get_sample_fmt_name(decoder.sample_fmt) + .ok_or_else(|| Error::TranscodeSampleFmtNameMissing(decoder.sample_fmt))? + .to_str()?, + ":channel_layout=", + decoder.ch_layout().describe()?.to_str()? + ); + let source_arg = CString::new(source_arg)?; + let mut source = + graph.filter.create_filter_context(&source_ref, c"in", Some(&source_arg))?; + + let mut sink = graph.filter.create_filter_context(&sink_ref, c"out", None)?; + sink.opt_set_bin(c"sample_rates", &encoder.sample_rate)?; + sink.opt_set_bin(c"sample_fmts", &encoder.sample_fmt)?; + sink.opt_set(c"ch_layouts", &encoder.ch_layout().describe()?)?; + + // Yes. The output name is in. + let outputs = AVFilterInOut::new(c"in", &mut source, 0); + let inputs = AVFilterInOut::new(c"out", &mut sink, 0); + graph.filter.parse_ptr(&graph.spec, Some(inputs), Some(outputs))?; + + graph.filter.config()?; + + Ok(Self { source, sink }) + } + + fn filter_and_encode( + &mut self, + output: &mut Output, + frame: Option, + ) -> Result<(), Error> { + self.source.buffersrc_add_frame(frame, None)?; + + loop { + let frame = match self.sink.buffersink_get_frame(None) { + Err(RsmpegError::BufferSinkDrainError | RsmpegError::BufferSinkEofError) => { + break Ok(()); + } + result => result?, + }; + output.encode(Some(&frame))?; + } + } +} + +impl Transcoder { + #[instrument(err)] + pub fn spawn( + input: impl Into + Debug, + sink: Sink, + bitrate: u32, + offset: u32, + ) -> Result>, Error> { + let mut transcoder = Self::new(&CString::new(input.into())?, sink, bitrate, offset)?; + + let span = tracing::Span::current(); + Ok(tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + transcoder.transcode() + })) + } + + fn new(input: &CStr, sink: Sink, bitrate: u32, offset: u32) -> Result { + let input = Input::new(input)?; + let output = Output::new(sink, bitrate, &input.decoder)?; + let graph = Graph::new(&input.decoder, &output.encoder, offset)?; + Ok(Self { input, output, graph }) + } + + #[instrument(skip_all, ret(level = "debug"), err(level = "debug"))] + pub fn transcode(&mut self) -> Result<(), Error> { + let mut filter = Filter::new(&self.graph, &self.input.decoder, &self.output.encoder)?; + + loop { + let packet = self.input.context.read_packet()?; + + // Ignore non audio stream packets. + if packet.as_ref().is_some_and(|p| p.stream_index != self.input.index) { + continue; + } + + self.input.decoder.send_packet(packet.as_ref())?; + + // If packet is none, we are at input EOF. + // The decoder is flushed by passing a none packet as above. + if packet.is_none() { + break; + } + + loop { + let frame = match self.input.decoder.receive_frame() { + Err(RsmpegError::DecoderDrainError | RsmpegError::DecoderFlushedError) => { + break; + } + result => result?, + }; + filter.filter_and_encode(&mut self.output, Some(frame))?; + } + } + + // Flush the filter graph by pushing none packet to its source. + filter.filter_and_encode(&mut self.output, None)?; + + self.output.flush()?; + self.output.context.write_trailer()?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use futures_lite::{stream, StreamExt}; + use nghe_api::common::format; + use typed_path::Utf8NativePathBuf; + + use super::*; + use crate::config; + + impl Transcoder { + pub async fn spawn_collect( + input: impl Into + Debug, + config: &config::Transcode, + format: format::Transcode, + bitrate: u32, + offset: u32, + ) -> Vec { + let (sink, rx) = Sink::new(config, format, None::).await.unwrap(); + let handle = Transcoder::spawn(input, sink, bitrate, offset).unwrap(); + let data = rx.into_stream().map(stream::iter).flatten().collect().await; + handle.await.unwrap().unwrap(); + data + } + } +} + +#[cfg(test)] +mod tests { + use nghe_api::common::format; + use rstest::rstest; + use typed_path::Utf8NativePath; + + use super::*; + use crate::config; + + #[cfg(hearing_test)] + #[rstest] + #[case(format::Transcode::Opus, 64)] + #[case(format::Transcode::Mp3, 320)] + #[tokio::test] + async fn test_hearing( + #[case] format: format::Transcode, + #[case] bitrate: u32, + #[values(0, 10)] offset: u32, + ) { + let input = env!("NGHE_HEARING_TEST_INPUT"); + let config = config::Transcode::default(); + let data = Transcoder::spawn_collect(input, &config, format, bitrate, offset).await; + + tokio::fs::write( + Utf8NativePath::new(env!("NGHE_HEARING_TEST_OUTPUT")) + .join(concat_string!(bitrate.to_string(), "-", offset.to_string())) + .with_extension(format.as_ref()), + &data, + ) + .await + .unwrap(); + } +} diff --git a/nghe-proc-macro/Cargo.toml b/nghe-proc-macro/Cargo.toml new file mode 100644 index 000000000..df69fe818 --- /dev/null +++ b/nghe-proc-macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "nghe_proc_macro" +version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[lib] +proc-macro = true + +[dependencies] +bon = { workspace = true } +concat-string = { workspace = true } +convert_case = { workspace = true } + +deluxe = { version = "0.5.0" } +proc-macro2 = { version = "1.0.86" } +quote = { version = "1.0.36" } +syn = { version = "2.0.68", features = ["fold", "full"] } diff --git a/nghe-proc-macro/src/api.rs b/nghe-proc-macro/src/api.rs new file mode 100644 index 000000000..6bba7d150 --- /dev/null +++ b/nghe-proc-macro/src/api.rs @@ -0,0 +1,210 @@ +use concat_string::concat_string; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse_quote, parse_str, Error}; + +#[derive(Debug, deluxe::ExtractAttributes)] +#[deluxe(attributes(endpoint))] +struct Endpoint { + path: String, + #[deluxe(default = true)] + json: bool, + #[deluxe(default = true)] + binary: bool, + #[deluxe(default = false)] + url_only: bool, + #[deluxe(default = true)] + same_crate: bool, +} + +#[derive(Debug, deluxe::ParseMetaItem)] +struct Derive { + #[deluxe(default = false)] + request: bool, + #[deluxe(default = false)] + response: bool, + #[deluxe(default = false)] + endpoint: bool, + #[deluxe(default = true)] + debug: bool, + #[deluxe(default = true)] + json: bool, + #[deluxe(default = true)] + binary: bool, + #[deluxe(default = true)] + fake: bool, + #[deluxe(default = true)] + copy: bool, + #[deluxe(default = true)] + eq: bool, + #[deluxe(default = true)] + ord: bool, + #[deluxe(default = true)] + test_only: bool, +} + +pub fn derive_endpoint(item: TokenStream) -> Result { + let mut input: syn::DeriveInput = syn::parse2(item)?; + let Endpoint { path, json, binary, url_only, same_crate } = + deluxe::extract_attributes(&mut input)?; + + let ident = &input.ident; + if ident != "Request" { + return Err(syn::Error::new( + ident.span(), + "Struct derived with `Endpoint` should be named `Request`", + )); + } + + let crate_path = if same_crate { format_ident!("crate") } else { format_ident!("nghe_api") }; + + let impl_json = if json { + let url = concat_string!("/rest/", &path); + let url_view = concat_string!("/rest/", &path, ".view"); + + let impl_endpoint = if url_only { + quote! {} + } else { + quote! { + impl #crate_path::common::JsonEndpoint for #ident { + type Response = Response; + } + } + }; + + quote! { + impl #crate_path::common::JsonURL for #ident { + const URL: &'static str = #url; + const URL_VIEW: &'static str = #url_view; + } + + #impl_endpoint + } + } else { + quote! {} + }; + + let impl_binary = if binary { + let url_binary = concat_string!("/rest/", &path, ".bin"); + + let impl_endpoint = if url_only { + quote! {} + } else { + quote! { + impl #crate_path::common::BinaryEndpoint for #ident { + type Response = Response; + } + } + }; + + quote! { + impl #crate_path::common::BinaryURL for #ident { + const URL_BINARY: &'static str = #url_binary; + } + + #impl_endpoint + } + } else { + quote! {} + }; + + Ok(quote! { + #impl_json + #impl_binary + }) +} + +pub fn derive(args: TokenStream, item: TokenStream) -> Result { + let args: Derive = deluxe::parse2(args)?; + let input: syn::DeriveInput = syn::parse2(item)?; + + let ident = input.ident.to_string(); + let is_request_struct = ident == "Request"; + let is_request = args.request || ident.ends_with("Request"); + let is_response = args.response || ident.ends_with("Response"); + + let is_enum = matches!(input.data, syn::Data::Enum(_)); + + let mut derives: Vec = vec![]; + let mut attributes: Vec = vec![]; + + if args.json { + if is_request { + derives.push(parse_str("::serde::Deserialize")?); + } + if is_response { + derives.push(parse_str("::serde::Serialize")?); + } + } + + if args.debug { + derives.push(parse_str("Debug")?); + } + if args.binary { + derives.push(parse_str("bitcode::Encode")?); + derives.push(parse_str("bitcode::Decode")?); + } + if args.endpoint || is_request_struct { + derives.push(parse_str("nghe_proc_macro::Endpoint")?); + if !args.json { + attributes.push(parse_quote!(#[endpoint(json = false)])); + } + if !args.binary { + attributes.push(parse_quote!(#[endpoint(binary = false)])); + } + } + + if args.json { + if is_enum { + attributes.push(parse_quote!(#[serde(rename_all_fields = "camelCase")])); + } + attributes.push(parse_quote!(#[serde(rename_all = "camelCase")])); + }; + + if is_request && args.fake { + attributes + .push(parse_quote!(#[cfg_attr(any(test, feature = "fake"), derive(fake::Dummy))])); + } + + if is_enum { + derives.extend_from_slice( + &["Clone", "PartialEq", "Eq", "PartialOrd", "Ord"] + .into_iter() + .map(parse_str) + .try_collect::>()?, + ); + if args.copy { + derives.push(parse_str("Copy")?); + } + } + + if !is_enum && args.eq { + if args.test_only { + attributes.push( + parse_quote!(#[cfg_attr(any(test, feature = "test"), derive(PartialEq, Eq))]), + ); + } else { + derives.extend_from_slice( + &["PartialEq", "Eq"].into_iter().map(parse_str).try_collect::>()?, + ); + } + } + + if !is_enum && args.ord { + if args.test_only { + attributes.push( + parse_quote!(#[cfg_attr(any(test, feature = "test"), derive(PartialOrd, Ord))]), + ); + } else { + derives.extend_from_slice( + &["PartialOrd", "Ord"].into_iter().map(parse_str).try_collect::>()?, + ); + } + } + + Ok(quote! { + #[derive(#(#derives),*)] + #( #attributes )* + #input + }) +} diff --git a/nghe-proc-macro/src/backend.rs b/nghe-proc-macro/src/backend.rs new file mode 100644 index 000000000..7c2514d5a --- /dev/null +++ b/nghe-proc-macro/src/backend.rs @@ -0,0 +1,378 @@ +#![allow(clippy::too_many_lines)] + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::spanned::Spanned; +use syn::{parse_quote, Error}; + +#[derive(Debug, deluxe::ParseMetaItem)] +struct Handler { + #[deluxe(default = "trace".into())] + ret_level: String, + role: Option, + #[deluxe(default = true)] + json: bool, + #[deluxe(default = true)] + binary: bool, + #[deluxe(default = vec![])] + headers: Vec, + #[deluxe(default = true)] + need_auth: bool, +} + +#[derive(Debug, deluxe::ParseMetaItem)] +struct BuildRouter { + modules: Vec, + #[deluxe(default = false)] + filesystem: bool, + #[deluxe(default = vec![])] + extensions: Vec, +} + +#[derive(Debug, bon::Builder)] +struct ModuleConfig { + pub ident: syn::Ident, + #[builder(default = true)] + pub json: bool, + #[builder(default = true)] + pub binary: bool, +} + +impl TryFrom for ModuleConfig { + type Error = Error; + + fn try_from(value: syn::Expr) -> Result { + let span = value.span(); + match value { + syn::Expr::Path(syn::ExprPath { path, .. }) => { + let ident = path + .get_ident() + .ok_or_else(|| Error::new(span, "Only `Path` with one segment is supported"))? + .to_owned(); + Ok(Self::builder().ident(ident).build()) + } + syn::Expr::Struct(syn::ExprStruct { path, fields, .. }) => { + let ident = path + .get_ident() + .ok_or_else(|| { + Error::new(span, "Only `Struct` path with one segment is supported") + })? + .to_owned(); + + let extract_bool = |key: &'static str| -> Option { + fields.iter().find_map(|value| { + if let syn::Member::Named(ref ident) = value.member + && ident == key + && let syn::Expr::Lit(syn::ExprLit { ref lit, .. }) = value.expr + && let syn::Lit::Bool(syn::LitBool { value, .. }) = lit + { + Some(*value) + } else { + None + } + }) + }; + + Ok(Self::builder() + .ident(ident) + .maybe_json(extract_bool("json")) + .maybe_binary(extract_bool("binary")) + .build()) + } + _ => Err(Error::new(span, "Only `Path` and `Struct` expressions are supported")), + } + } +} + +pub fn handler(attr: TokenStream, item: TokenStream) -> Result { + let input: syn::ItemFn = syn::parse2(item)?; + let Handler { ret_level, role, json, binary, headers, need_auth } = deluxe::parse2(attr)?; + + let ident = &input.sig.ident; + if ident != "handler" { + return Err(syn::Error::new( + ident.span(), + "Function derived with `handler` should be named `handler`", + )); + } + + let mut skip_debugs: Vec<&syn::Ident> = vec![]; + let mut common_args: Vec = vec![]; + let mut pass_args: Vec = vec![]; + + for fn_arg in &input.sig.inputs { + if let syn::FnArg::Typed(arg) = fn_arg + && let syn::Pat::Ident(pat) = arg.pat.as_ref() + && pat.ident != "request" + { + match pat.ident.to_string().as_str() { + "database" | "_database" => { + skip_debugs.push(&pat.ident); + common_args.push(parse_quote! { + extract::State(database): extract::State + }); + pass_args.push(parse_quote!(&database)); + } + "user_id" => { + pass_args.push(parse_quote!(user.id)); + } + _ => match arg.ty.as_ref() { + syn::Type::Path(ty) => { + if headers.contains(&pat.ident) { + if let Some(segment) = ty.path.segments.last() + && segment.ident == "Option" + && let syn::PathArguments::AngleBracketed(angle) = + &segment.arguments + && let Some(syn::GenericArgument::Type(syn::Type::Path(ty))) = + angle.args.first() + { + common_args + .push(parse_quote!(#pat: Option>)); + pass_args.push(parse_quote!(#pat.map(|header| header.0))); + } + } else { + if pat.ident == "config" { + skip_debugs.push(&pat.ident); + } + common_args.push( + parse_quote!(extract::Extension(#pat): extract::Extension<#ty>), + ); + pass_args.push(parse_quote!(#pat)); + } + } + syn::Type::Reference(ty) => { + if pat.ident == "filesystem" || pat.ident == "config" { + skip_debugs.push(&pat.ident); + } + let ty = ty.elem.as_ref(); + common_args + .push(parse_quote!(extract::Extension(#pat): extract::Extension<#ty>)); + pass_args.push(parse_quote!(&#pat)); + } + _ => { + return Err(syn::Error::new( + arg.ty.span(), + "Only path type and reference type are supported for handler function", + )); + } + }, + } + } + } + + let (authorize_fn, missing_role) = if let Some(role) = role { + if role == "admin" { + (quote! { role.admin }, role.to_string()) + } else { + (quote! { role.admin || role.#role }, role.to_string()) + } + } else { + (quote! { true }, String::default()) + }; + + let is_binary_respone = if let syn::ReturnType::Type(_, ty) = &input.sig.output + && let syn::Type::Path(ty) = ty.as_ref() + && let Some(segment) = ty.path.segments.last() + && segment.ident == "Result" + && let syn::PathArguments::AngleBracketed(angle) = &segment.arguments + && let Some(syn::GenericArgument::Type(syn::Type::Path(ty))) = angle.args.first() + && let Some(segment) = ty.path.segments.first() + && segment.ident == "binary" + && let Some(segment) = ty.path.segments.last() + && segment.ident == "Response" + { + true + } else { + false + }; + + pass_args.push(parse_quote!(user.request)); + + let json_handler = if json { + let json_get_args = + [common_args.as_slice(), [parse_quote!(user: GetUser)].as_slice()] + .concat(); + let json_post_args = + [common_args.as_slice(), [parse_quote!(user: PostUser)].as_slice()].concat(); + + if is_binary_respone { + quote! { + #[axum::debug_handler] + pub async fn json_get_handler( + #( #json_get_args ),* + ) -> Result { + #ident(#( #pass_args ),*).await + } + + #[axum::debug_handler] + pub async fn json_post_handler( + #( #json_post_args ),* + ) -> Result { + #ident(#( #pass_args ),*).await + } + } + } else { + quote! { + #[axum::debug_handler] + pub async fn json_get_handler( + #( #json_get_args ),* + ) -> Result< + axum::Json::Response> + >, Error> { + let response = #ident(#( #pass_args ),*).await?; + Ok(axum::Json(SubsonicResponse::new(response))) + } + + #[axum::debug_handler] + pub async fn json_post_handler( + #( #json_post_args ),* + ) -> Result< + axum::Json::Response> + >, Error> { + let response = #ident(#( #pass_args ),*).await?; + Ok(axum::Json(SubsonicResponse::new(response))) + } + } + } + } else { + quote! {} + }; + + let binary_handler = if binary { + let binary_args = [ + common_args.as_slice(), + [parse_quote!(user: BinaryUser)].as_slice(), + ] + .concat(); + + if is_binary_respone { + quote! { + #[axum::debug_handler] + pub async fn binary_handler( + #( #binary_args ),* + ) -> Result { + #ident(#( #pass_args ),*).await + } + } + } else { + quote! { + #[axum::debug_handler] + pub async fn binary_handler( + #( #binary_args ),* + ) -> Result, Error> { + let response = #ident(#( #pass_args ),*).await?; + Ok(bitcode::encode(&response)) + } + } + } + } else { + quote! {} + }; + + Ok(quote! { + #[tracing::instrument(skip(#( #skip_debugs ),*), ret(level = #ret_level), err)] + #input + + use axum::extract; + use nghe_api::common::{JsonEndpoint, SubsonicResponse}; + + use crate::auth::{Authorize, BinaryUser, GetUser, PostUser}; + + impl Authorize for Request { + fn authorize(role: crate::orm::users::Role) -> Result<(), Error> { + if #authorize_fn { + Ok(()) + } else { + Err(Error::MissingRole(#missing_role)) + } + } + } + + #json_handler + #binary_handler + }) +} + +pub fn build_router(item: TokenStream) -> Result { + let input = deluxe::parse2::(item)?; + let endpoints: Vec<_> = input + .modules + .into_iter() + .map(|module| { + let config: ModuleConfig = module.try_into()?; + let module = config.ident; + + let json_get_handler = quote! { #module::json_get_handler }; + let json_post_handler = quote! { #module::json_post_handler }; + let binary_handler = quote! { #module::binary_handler }; + + let mut routers = vec![]; + + if config.json { + let request = quote! { <#module::Request as nghe_api::common::JsonURL> }; + + routers.push(quote! { + route( + #request::URL, + axum::routing::get(#json_get_handler).post(#json_post_handler) + ) + }); + routers.push(quote! { + route( + #request::URL_VIEW, + axum::routing::get(#json_get_handler).post(#json_post_handler) + ) + }); + } + + if config.binary { + let request = quote! { <#module::Request as nghe_api::common::BinaryURL> }; + + routers.push(quote! { + route( + #request::URL_BINARY, + axum::routing::post(#binary_handler) + ) + }); + } + + Ok::<_, Error>(routers) + }) + .try_collect::>()? + .into_iter() + .flatten() + .collect(); + + let mut router_args: Vec = vec![]; + let mut router_layers: Vec = vec![]; + + if input.filesystem { + router_args.push(parse_quote!(filesystem: crate::filesystem::Filesystem)); + router_layers.push(parse_quote!(layer(axum::Extension(filesystem)))); + } + + for extension in input.extensions { + let arg = extension + .segments + .iter() + .map(|segment| segment.ident.to_string().to_case(Case::Snake)) + .collect::>() + .join("_"); + let arg = format_ident!("{arg}"); + router_args.push(parse_quote!(#arg: #extension)); + router_layers.push(parse_quote!(layer(axum::Extension(#arg)))); + } + + let router_body: syn::Expr = if router_layers.is_empty() { + parse_quote!(axum::Router::new().#( #endpoints ).*) + } else { + parse_quote!(axum::Router::new().#( #endpoints ).*.#( #router_layers ).*) + }; + + Ok(quote! { + pub fn router(#( #router_args ),*) -> axum::Router { + #router_body + } + }) +} diff --git a/nghe-proc-macro/src/lib.rs b/nghe-proc-macro/src/lib.rs new file mode 100644 index 000000000..c3d3f5858 --- /dev/null +++ b/nghe-proc-macro/src/lib.rs @@ -0,0 +1,46 @@ +#![feature(iterator_try_collect)] +#![feature(let_chains)] + +use proc_macro::TokenStream; + +mod api; +mod backend; +mod orm; + +trait IntoTokenStream { + fn into_token_stream(self) -> TokenStream; +} + +impl IntoTokenStream for Result { + fn into_token_stream(self) -> TokenStream { + match self { + Ok(r) => r.into(), + Err(e) => e.to_compile_error().into(), + } + } +} + +#[proc_macro_derive(Endpoint, attributes(endpoint))] +pub fn derive_endpoint(item: TokenStream) -> TokenStream { + api::derive_endpoint(item.into()).into_token_stream() +} + +#[proc_macro_attribute] +pub fn api_derive(attr: TokenStream, item: TokenStream) -> TokenStream { + api::derive(attr.into(), item.into()).into_token_stream() +} + +#[proc_macro_attribute] +pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream { + backend::handler(attr.into(), item.into()).into_token_stream() +} + +#[proc_macro] +pub fn build_router(item: TokenStream) -> TokenStream { + backend::build_router(item.into()).into_token_stream() +} + +#[proc_macro_attribute] +pub fn check_music_folder(attr: TokenStream, item: TokenStream) -> TokenStream { + orm::check_music_folder(attr.into(), item.into()).into_token_stream() +} diff --git a/nghe-proc-macro/src/orm.rs b/nghe-proc-macro/src/orm.rs new file mode 100644 index 000000000..7ced573ed --- /dev/null +++ b/nghe-proc-macro/src/orm.rs @@ -0,0 +1,52 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::fold::Fold; +use syn::{parse_quote, Error}; + +#[derive(Debug, deluxe::ParseMetaItem)] +struct CheckMusicFolder { + #[deluxe(default = parse_quote!(request.music_folder_ids.as_ref()))] + input: syn::Expr, + #[deluxe(default = parse_quote!(with_user_id))] + user_id: syn::Ident, + #[deluxe(default = parse_quote!(with_music_folder))] + music_folder: syn::Ident, +} + +impl Fold for CheckMusicFolder { + fn fold_expr_call(&mut self, expr: syn::ExprCall) -> syn::ExprCall { + if let syn::Expr::Path(syn::ExprPath { path, .. }) = expr.func.as_ref() + && let Some(segment) = path.segments.last() + && segment.ident == self.user_id + && expr.args.len() == 1 + && let Some(syn::Expr::Path(arg)) = expr.args.last() + && let Some(arg) = arg.path.get_ident() + && arg == "user_id" + { + let mut with_music_folder_path = path.clone(); + with_music_folder_path.segments.pop(); + with_music_folder_path.segments.push(syn::PathSegment { + ident: self.music_folder.clone(), + arguments: syn::PathArguments::None, + }); + parse_quote! { #with_music_folder_path(user_id, music_folder_ids) } + } else { + expr + } + } +} + +pub fn check_music_folder(args: TokenStream, item: TokenStream) -> Result { + let check_user_id: syn::Expr = syn::parse2(item)?; + let mut args: CheckMusicFolder = deluxe::parse2(args)?; + let check_music_folder: syn::Expr = args.fold_expr(check_user_id.clone()); + + let input = args.input; + Ok(quote! { + if let Some(music_folder_ids) = #input { + #check_music_folder + } else { + #check_user_id + } + }) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a1ba14f8f..6cfccbde4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly-2024-10-22" +channel = "nightly-2024-11-22"