Compare commits
3 Commits
123a409e14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f33bcdf32 | ||
|
|
c862669b08 | ||
|
|
cae0136aaf |
182
Cargo.lock
generated
182
Cargo.lock
generated
@@ -2,6 +2,15 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -62,15 +71,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
@@ -80,9 +89,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.55"
|
||||
version = "1.2.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -131,9 +140,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
checksum = "2163a0e204a148662b6b6816d4b5d5668a5f2f8df498ccbd5cd0e864e78fecba"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
@@ -245,35 +254,35 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -386,9 +395,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
version = "0.3.87"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -402,9 +411,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
@@ -431,12 +440,51 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "maud"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"http",
|
||||
"itoa",
|
||||
"maud_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maud_macros"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -543,6 +591,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2-diagnostics"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -572,6 +632,23 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
@@ -700,6 +777,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -723,9 +806,12 @@ dependencies = [
|
||||
"axum",
|
||||
"diesel",
|
||||
"dotenvy",
|
||||
"maud",
|
||||
"md5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -749,9 +835,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -868,6 +954,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -918,19 +1020,23 @@ version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.23"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
@@ -944,6 +1050,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -952,9 +1064,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
version = "0.2.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -965,9 +1077,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
version = "0.2.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -975,9 +1087,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
version = "0.2.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -988,9 +1100,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
version = "0.2.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -1086,6 +1198,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.20"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
@@ -13,4 +13,7 @@ axum = "0.8.8"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tracing-subscriber = "0.3.22"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||
md5 = "0.8.0"
|
||||
tower-http = {version = "0.6.8", features = ["trace"]}
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
|
||||
51
README.md
51
README.md
@@ -1,18 +1,22 @@
|
||||
# SoundSonic
|
||||
|
||||
A Rust-based music streaming server implementing the OpenSubsonic API protocol.
|
||||
A Rust-based server implementing parts of the OpenSubsonic API protocol.
|
||||
|
||||
## Overview
|
||||
|
||||
SoundSonic is a lightweight music streaming server built with Rust that implements the [OpenSubsonic](https://opensubsonic.netlify.app/) API specification. It allows you to stream your music collection to any OpenSubsonic-compatible client.
|
||||
SoundSonic is a lightweight server built with Rust that implements the [OpenSubsonic](https://opensubsonic.netlify.app/) API specification. The long-term goal is to support streaming a local music collection to OpenSubsonic-compatible clients.
|
||||
|
||||
## Status
|
||||
|
||||
This project is under active development. Only a small subset of the OpenSubsonic API is implemented today (see **API Endpoints**).
|
||||
|
||||
## Features
|
||||
|
||||
- **OpenSubsonic API Support**: Compatible with any client supporting the OpenSubsonic protocol
|
||||
- **User Management**: Role-based access control with customizable user permissions
|
||||
- **SQLite Database**: Lightweight local storage with Diesel ORM
|
||||
- **RESTful API**: Clean, well-structured API endpoints
|
||||
- **Async Runtime**: Built on Tokio for high performance
|
||||
- **OpenSubsonic Response Envelope**: Consistent `subsonic-response` JSON format
|
||||
- **OpenSubsonic Extensions Endpoint**: Exposes supported extensions via `getOpenSubsonicExtensions`
|
||||
- **User Creation**: `createUser.view` endpoint with role/permission fields
|
||||
- **SQLite + Diesel**: Lightweight local storage
|
||||
- **Axum + Tokio**: Async HTTP server runtime
|
||||
|
||||
## Technologies
|
||||
|
||||
@@ -25,18 +29,23 @@ SoundSonic is a lightweight music streaming server built with Rust that implemen
|
||||
## Prerequisites
|
||||
|
||||
- Rust (latest stable version)
|
||||
- SQLite
|
||||
- SQLite (system library)
|
||||
- Diesel CLI (for database migrations)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
git clone <your-repository-url>
|
||||
cd soundsonic
|
||||
```
|
||||
|
||||
2. Set up the database URL:
|
||||
2. Install Diesel CLI (SQLite):
|
||||
```bash
|
||||
cargo install diesel_cli --no-default-features --features sqlite
|
||||
```
|
||||
|
||||
3. Set up the database URL:
|
||||
```bash
|
||||
export DATABASE_URL=database.db
|
||||
```
|
||||
@@ -46,27 +55,37 @@ Or create a `.env` file:
|
||||
DATABASE_URL=database.db
|
||||
```
|
||||
|
||||
3. Run database migrations:
|
||||
4. Run database migrations:
|
||||
```bash
|
||||
diesel migration run
|
||||
```
|
||||
|
||||
4. Build and run the server:
|
||||
5. Build and run the server:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
The server will start on `http://0.0.0.0:3311`
|
||||
The server will start on `http://0.0.0.0:3311`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are nested under the `/rest` prefix.
|
||||
|
||||
### System
|
||||
|
||||
- `GET/POST /rest/getOpenSubsonicExtensions` - Get supported OpenSubsonic extensions
|
||||
- `GET /rest/getOpenSubsonicExtensions`
|
||||
- `POST /rest/getOpenSubsonicExtensions`
|
||||
|
||||
### User Management
|
||||
|
||||
- `GET/POST /rest/createUser.view` - Create a new user with specified permissions
|
||||
- `GET /rest/createUser.view`
|
||||
- `POST /rest/createUser.view` (accepts `application/x-www-form-urlencoded`)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:3311/rest/createUser.view' \
|
||||
-d 'username=alice&password=secret&email=alice@example.com&adminRole=true&streamRole=true'
|
||||
```
|
||||
|
||||
### Request/Response Format
|
||||
|
||||
@@ -137,7 +156,7 @@ diesel migration run
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
MIT (see `LICENSE`).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
150
src/admin/layout.rs
Normal file
150
src/admin/layout.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
|
||||
pub fn layout(title: &str, content: Markup) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
title { "SoundSonic Admin - " (title) }
|
||||
script src="https://cdn.tailwindcss.com" {}
|
||||
// Tailwind Config for custom colors/fonts if needed
|
||||
script {
|
||||
(maud::PreEscaped(r##"
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"##))
|
||||
}
|
||||
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
|
||||
// Add Phosphor icons for beautiful lightweight UI icons
|
||||
script src="https://unpkg.com/@phosphor-icons/web" {}
|
||||
|
||||
style {
|
||||
(maud::PreEscaped(r##"
|
||||
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
|
||||
/* Glassmorphism utilities */
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.4);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.nav-link { transition: all 0.2s ease-in-out; border-left: 3px solid transparent; }
|
||||
.nav-link:hover { background-color: rgba(51, 65, 85, 0.5); color: #fff; border-left-color: #3b82f6; }
|
||||
.nav-link.active { background-color: rgba(51, 65, 85, 0.8); color: #fff; border-left-color: #3b82f6; font-weight: 500; }
|
||||
|
||||
/* Custom scrollbar for dark mode */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
"##))
|
||||
}
|
||||
}
|
||||
body class="h-screen flex overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
|
||||
// Sidebar
|
||||
aside class="w-64 glass flex flex-col z-20 flex-shrink-0" {
|
||||
div class="h-16 flex items-center px-6 font-bold text-xl tracking-tight border-b border-white/10 gap-2 text-white" {
|
||||
i class="ph-fill ph-waves text-primary-500 text-2xl" {}
|
||||
"SoundSonic"
|
||||
}
|
||||
nav class="flex-1 overflow-y-auto py-4 space-y-1" {
|
||||
// Using simplistic path matching logic conceptually handled in UI. We'll just define the links.
|
||||
a href="/admin" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-squares-four text-lg" {} "Dashboard"
|
||||
}
|
||||
|
||||
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Manage" }
|
||||
a href="/admin/users" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-users text-lg" {} "Users & Roles"
|
||||
}
|
||||
a href="/admin/library" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-books text-lg" {} "Library"
|
||||
}
|
||||
a href="/admin/clients" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-devices text-lg" {} "API Clients"
|
||||
}
|
||||
|
||||
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Server" }
|
||||
a href="/admin/playback" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-play-circle text-lg" {} "Playback/Stream"
|
||||
}
|
||||
a href="/admin/settings" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-gear text-lg" {} "Settings"
|
||||
}
|
||||
a href="/admin/jobs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-arrows-clockwise text-lg" {} "Background Jobs"
|
||||
}
|
||||
|
||||
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Diagnostics" }
|
||||
a href="/admin/logs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-terminal-window text-lg" {} "Logs & Debug"
|
||||
}
|
||||
a href="/admin/about" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||
i class="ph ph-info text-lg" {} "About"
|
||||
}
|
||||
}
|
||||
div class="p-4 border-t border-white/10" {
|
||||
div class="flex items-center gap-3 px-2" {
|
||||
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-sm font-bold shadow-lg shadow-primary-500/20" {
|
||||
"A"
|
||||
}
|
||||
div class="flex-1 min-w-0" {
|
||||
p class="text-sm font-medium text-white truncate" { "Administrator" }
|
||||
p class="text-xs text-slate-400 truncate" { "admin@local" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Content
|
||||
main class="flex-1 flex flex-col min-w-0 overflow-hidden relative" {
|
||||
// Decorative background glow
|
||||
div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-primary-900/20 blur-[120px] pointer-events-none" {}
|
||||
div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-900/10 blur-[120px] pointer-events-none" {}
|
||||
|
||||
header class="h-16 glass flex items-center justify-between px-8 z-10" {
|
||||
h1 class="text-xl font-semibold text-white tracking-tight flex items-center gap-2" {
|
||||
(title)
|
||||
}
|
||||
div class="flex items-center gap-4 text-slate-400" {
|
||||
button class="hover:text-white transition-colors" title="Notifications" { i class="ph ph-bell text-xl" {} }
|
||||
button class="hover:text-white transition-colors" title="Server Status" {
|
||||
div class="relative" {
|
||||
i class="ph ph-hard-drives text-xl" {}
|
||||
span class="absolute top-0 right-0 w-2 h-2 bg-emerald-500 rounded-full border border-slate-900" {}
|
||||
}
|
||||
}
|
||||
a href="/rest/ping.view" class="text-sm border border-slate-700 hover:bg-slate-800 px-3 py-1.5 rounded-md transition-colors flex items-center gap-2" {
|
||||
i class="ph ph-sign-out" {} "Exit Panel"
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="flex-1 overflow-y-auto p-8 z-10" {
|
||||
div class="max-w-7xl mx-auto space-y-6" {
|
||||
(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/admin/mod.rs
Normal file
3
src/admin/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod layout;
|
||||
pub mod routes;
|
||||
pub mod views;
|
||||
24
src/admin/routes.rs
Normal file
24
src/admin/routes.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use axum::{routing::get, Router};
|
||||
use crate::state::AppState;
|
||||
use crate::admin::views::{
|
||||
dashboard, users, library, playback, settings, logs, clients, jobs, about, login
|
||||
};
|
||||
|
||||
pub fn admin_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", get(login::login_form).post(login::login_post))
|
||||
.route("/", get(dashboard::index))
|
||||
|
||||
.route("/users", get(users::users))
|
||||
.route("/users/new", get(users::user_create))
|
||||
.route("/users/{id}/edit", get(users::user_edit))
|
||||
|
||||
.route("/library", get(library::library))
|
||||
.route("/playback", get(playback::playback))
|
||||
.route("/settings", get(settings::settings))
|
||||
|
||||
.route("/logs", get(logs::logs))
|
||||
.route("/clients", get(clients::clients))
|
||||
.route("/jobs", get(jobs::jobs))
|
||||
.route("/about", get(about::about))
|
||||
}
|
||||
99
src/admin/views/about.rs
Normal file
99
src/admin/views/about.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn about() -> Markup {
|
||||
layout("About & Diagnostics", html! {
|
||||
div class="max-w-3xl mx-auto space-y-6" {
|
||||
|
||||
// Server Identity Card
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden text-center p-8 space-y-4 relative" {
|
||||
div class="w-24 h-24 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 mx-auto flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20" {
|
||||
i class="ph-fill ph-waves text-5xl text-white" {}
|
||||
}
|
||||
div {
|
||||
h2 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic" }
|
||||
p class="text-primary-400 font-mono text-sm mt-1" { "v0.1.0-alpha.5 (build 4f8a9b2)" }
|
||||
}
|
||||
p class="text-slate-400 text-sm max-w-lg mx-auto" {
|
||||
"A blazing fast, lightweight Subsonic-compatible media server written in Rust."
|
||||
}
|
||||
|
||||
div class="flex justify-center gap-4 mt-6 pt-6 border-t border-white/5" {
|
||||
a href="#" class="text-slate-400 hover:text-white transition-colors flex flex-col items-center gap-1" {
|
||||
i class="ph ph-github-logo text-3xl" {}
|
||||
span class="text-xs font-medium" { "GitHub" }
|
||||
}
|
||||
a href="#" class="text-slate-400 hover:text-primary-400 transition-colors flex flex-col items-center gap-1" {
|
||||
i class="ph ph-bug text-3xl" {}
|
||||
span class="text-xs font-medium" { "Report Issue" }
|
||||
}
|
||||
a href="#" class="text-slate-400 hover:text-indigo-400 transition-colors flex flex-col items-center gap-1" {
|
||||
i class="ph ph-book-open text-3xl" {}
|
||||
span class="text-xs font-medium" { "Docs" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
|
||||
i class="ph ph-info" {} "System Details"
|
||||
}
|
||||
}
|
||||
div class="p-0" {
|
||||
table class="w-full text-sm" {
|
||||
tbody class="divide-y divide-white/5" {
|
||||
tr class="hover:bg-white/[0.02]" {
|
||||
td class="py-3 px-6 text-slate-500" { "OS" }
|
||||
td class="py-3 px-6 text-slate-300 font-mono text-right" { "Linux 6.8.0-generic" }
|
||||
}
|
||||
tr class="hover:bg-white/[0.02]" {
|
||||
td class="py-3 px-6 text-slate-500" { "Architecture" }
|
||||
td class="py-3 px-6 text-slate-300 font-mono text-right" { "x86_64" }
|
||||
}
|
||||
tr class="hover:bg-white/[0.02]" {
|
||||
td class="py-3 px-6 text-slate-500" { "Rust Version" }
|
||||
td class="py-3 px-6 text-slate-300 font-mono text-right" { "rustc 1.77.0" }
|
||||
}
|
||||
tr class="hover:bg-white/[0.02]" {
|
||||
td class="py-3 px-6 text-slate-500" { "Database" }
|
||||
td class="py-3 px-6 text-slate-300 font-mono text-right" { "SQLite 3.45.1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
|
||||
i class="ph ph-plugs-connected" {} "API Supported Extensions"
|
||||
}
|
||||
}
|
||||
div class="p-6 text-sm" {
|
||||
ul class="space-y-3" {
|
||||
li class="flex items-center gap-3 text-slate-300" {
|
||||
i class="ph-fill ph-check-circle text-emerald-400" {} "subsonic"
|
||||
}
|
||||
li class="flex items-center gap-3 text-slate-300" {
|
||||
i class="ph-fill ph-check-circle text-emerald-400" {} "transcodeOffset"
|
||||
}
|
||||
li class="flex items-center gap-3 text-slate-300" {
|
||||
i class="ph-fill ph-check-circle text-emerald-400" {} "podcast"
|
||||
}
|
||||
li class="flex items-center gap-3 text-slate-300" {
|
||||
i class="ph-fill ph-check-circle text-emerald-400" {} "videoConversion"
|
||||
}
|
||||
li class="flex items-center gap-3 text-slate-300" {
|
||||
i class="ph-fill ph-check-circle text-emerald-400" {} "playQueue"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
77
src/admin/views/clients.rs
Normal file
77
src/admin/views/clients.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn clients() -> Markup {
|
||||
layout("API Clients & Tokens", html! {
|
||||
div class="flex flex-col gap-6" {
|
||||
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-devices text-primary-400" {} "Connected Clients"
|
||||
}
|
||||
div class="flex gap-2" {
|
||||
span class="px-3 py-1 rounded bg-slate-800 text-slate-400 border border-white/5 text-xs font-medium" { "Token Auth Enabled" }
|
||||
}
|
||||
}
|
||||
div class="p-6 overflow-x-auto" {
|
||||
table class="w-full text-left" {
|
||||
thead class="bg-transparent text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
|
||||
tr {
|
||||
th class="pb-3 font-semibold" { "Client Name" }
|
||||
th class="pb-3 font-semibold" { "Version" }
|
||||
th class="pb-3 font-semibold" { "Owner / User" }
|
||||
th class="pb-3 font-semibold" { "Last Used" }
|
||||
th class="pb-3 font-semibold text-right" { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody class="text-sm divide-y divide-white/5" {
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
|
||||
i class="ph ph-device-mobile text-xl text-indigo-400" {}
|
||||
"Ultrasonic"
|
||||
}
|
||||
td class="py-4 text-slate-400" { "3.0.0-git" }
|
||||
td class="py-4 text-slate-300" { "admin" }
|
||||
td class="py-4 text-slate-400" { "2 hours ago" }
|
||||
td class="py-4 text-right" {
|
||||
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||
i class="ph ph-key" {} "Revoke Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
|
||||
i class="ph ph-browser text-xl text-sky-400" {}
|
||||
"WebPlayer"
|
||||
}
|
||||
td class="py-4 text-slate-400" { "1.0.0" }
|
||||
td class="py-4 text-slate-300" { "admin" }
|
||||
td class="py-4 text-slate-400" { "Right now" }
|
||||
td class="py-4 text-right" {
|
||||
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||
i class="ph ph-key" {} "Revoke Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
tr class="hover:bg-white/[0.02] transition-colors text-slate-500" {
|
||||
td class="py-4 font-medium flex items-center gap-3" {
|
||||
i class="ph ph-car text-xl" {}
|
||||
"Android Auto Sub"
|
||||
}
|
||||
td class="py-4" { "1.2" }
|
||||
td class="py-4" { "guest" }
|
||||
td class="py-4" { "6 months ago" }
|
||||
td class="py-4 text-right" {
|
||||
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||
i class="ph ph-key" {} "Revoke Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
171
src/admin/views/dashboard.rs
Normal file
171
src/admin/views/dashboard.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn index() -> Markup {
|
||||
layout("Dashboard Overview", html! {
|
||||
// Top Stats Row
|
||||
div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" {
|
||||
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||
i class="ph-fill ph-music-notes text-6xl text-primary-500" {}
|
||||
}
|
||||
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Songs" }
|
||||
p class="text-4xl font-bold text-white mb-1" { "42,891" }
|
||||
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||
i class="ph-bold ph-trend-up" {} "+124 this week"
|
||||
}
|
||||
}
|
||||
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||
i class="ph-fill ph-vinyl-record text-6xl text-purple-500" {}
|
||||
}
|
||||
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Albums" }
|
||||
p class="text-4xl font-bold text-white mb-1" { "3,402" }
|
||||
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||
i class="ph-bold ph-trend-up" {} "+12 this week"
|
||||
}
|
||||
}
|
||||
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||
i class="ph-fill ph-users text-6xl text-amber-500" {}
|
||||
}
|
||||
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Users" }
|
||||
p class="text-4xl font-bold text-white mb-1" { "128" }
|
||||
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||
i class="ph-bold ph-trend-up" {} "+3 this week"
|
||||
}
|
||||
}
|
||||
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||
i class="ph-fill ph-headphones text-6xl text-pink-500" {}
|
||||
}
|
||||
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Active Sessions" }
|
||||
p class="text-4xl font-bold text-white flex items-center gap-3 mb-1" {
|
||||
"14"
|
||||
span class="relative flex h-3 w-3" {
|
||||
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75" {}
|
||||
span class="relative inline-flex rounded-full h-3 w-3 bg-pink-500" {}
|
||||
}
|
||||
}
|
||||
p class="text-xs text-slate-400 flex items-center gap-1" {
|
||||
"Right now"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server Health & Quick Actions
|
||||
div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6" {
|
||||
|
||||
// Server Health
|
||||
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-heartbeat text-primary-400" {} "Server Health"
|
||||
}
|
||||
span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5" {
|
||||
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {}
|
||||
"Online"
|
||||
}
|
||||
}
|
||||
div class="p-6 grid grid-cols-2 md:grid-cols-4 gap-6" {
|
||||
div {
|
||||
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Uptime" }
|
||||
p class="text-xl font-medium text-white" { "14d 6h 22m" }
|
||||
}
|
||||
div {
|
||||
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "CPU Usage" }
|
||||
p class="text-xl font-medium text-white flex items-center gap-2" {
|
||||
"12%"
|
||||
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
|
||||
div class="h-full bg-primary-500 w-[12%]" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "RAM Usage" }
|
||||
p class="text-xl font-medium text-white flex items-center gap-2" {
|
||||
"2.4 GB"
|
||||
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
|
||||
div class="h-full bg-amber-500 w-[45%]" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Last Scan" }
|
||||
p class="text-xl font-medium text-white" { "2 mins ago" }
|
||||
}
|
||||
}
|
||||
|
||||
// Recent Errors
|
||||
div class="px-6 py-3 bg-black/30 border-t border-white/5 border-b border-white/5" {
|
||||
p class="text-xs font-medium text-slate-400 uppercase tracking-wider" { "Recent Log Activity" }
|
||||
}
|
||||
div class="flex-1 p-6 space-y-3 overflow-y-auto max-h-[300px] font-mono text-sm" {
|
||||
div class="flex gap-3 text-slate-300" {
|
||||
span class="text-slate-500 shrink-0" { "10:42:01 AM" }
|
||||
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
|
||||
span class="truncate" { "Library scan completed in 4.2s." }
|
||||
}
|
||||
div class="flex gap-3 text-slate-300" {
|
||||
span class="text-slate-500 shrink-0" { "10:39:15 AM" }
|
||||
span class="text-amber-400 shrink-0 w-12" { "WARN " }
|
||||
span class="truncate" { "Failed to resolve cover art for /music/Unknown/Track01.mp3" }
|
||||
}
|
||||
div class="flex gap-3 text-slate-300" {
|
||||
span class="text-slate-500 shrink-0" { "09:12:33 AM" }
|
||||
span class="text-rose-400 shrink-0 w-12" { "ERROR" }
|
||||
span class="truncate" { "Database connection timeout occurred during sync." }
|
||||
}
|
||||
div class="flex gap-3 text-slate-300" {
|
||||
span class="text-slate-500 shrink-0" { "08:00:00 AM" }
|
||||
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
|
||||
span class="truncate" { "Daily backup completed successfully." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-lightning text-amber-400" {} "Quick Actions"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-4" {
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600 hover:bg-primary-500 text-white transition-colors group shadow-lg shadow-primary-900/20" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-arrows-clockwise text-xl group-hover:rotate-180 transition-transform duration-500" {}
|
||||
span class="font-medium" { "Start Library Scan" }
|
||||
}
|
||||
i class="ph ph-caret-right text-slate-300" {}
|
||||
}
|
||||
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-stop-circle text-xl text-rose-400" {}
|
||||
span class="font-medium inline-block" { "Stop Current Scan" }
|
||||
}
|
||||
span class="text-xs bg-black/30 px-2 py-1 rounded text-slate-400" { "Idle" }
|
||||
}
|
||||
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-broom text-xl text-amber-400" {}
|
||||
span class="font-medium inline-block" { "Clear Image Cache" }
|
||||
}
|
||||
span class="text-xs text-slate-400" { "442 MB" }
|
||||
}
|
||||
|
||||
hr class="border-white/5 my-2" {}
|
||||
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-rose-900/40 hover:bg-rose-900/80 text-rose-200 transition-colors border border-rose-500/20" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-power text-xl" {}
|
||||
span class="font-medium inline-block" { "Restart Server" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
109
src/admin/views/jobs.rs
Normal file
109
src/admin/views/jobs.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn jobs() -> Markup {
|
||||
layout("Background Jobs", html! {
|
||||
div class="flex justify-between items-center mb-6" {
|
||||
div {
|
||||
h2 class="text-lg font-medium text-white" { "Job Queue" }
|
||||
p class="text-sm text-slate-400 mt-1" { "Monitor long-running tasks and scanners." }
|
||||
}
|
||||
button class="text-slate-300 hover:text-white transition-colors bg-black/30 p-2 rounded-lg border border-white/5 flex items-center gap-2" title="Refresh" {
|
||||
i class="ph ph-arrows-clockwise text-lg" {} "Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="p-6 space-y-4" {
|
||||
|
||||
// Active Job
|
||||
div class="p-4 rounded-xl border border-primary-500/30 bg-primary-900/10 flex flex-col md:flex-row gap-4 items-center" {
|
||||
div class="w-12 h-12 rounded-full bg-primary-500/20 text-primary-400 flex items-center justify-center shrink-0 border border-primary-500/30" {
|
||||
i class="ph ph-magnifying-glass text-2xl animate-pulse" {}
|
||||
}
|
||||
div class="flex-1 w-full" {
|
||||
div class="flex justify-between items-center mb-2" {
|
||||
h3 class="text-sm font-medium text-primary-200" { "Library Metadata Rescan" }
|
||||
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-primary-500/20 text-primary-400 border border-primary-500/30" { "Running" }
|
||||
}
|
||||
div class="flex justify-between text-xs text-primary-400/80 mb-1" {
|
||||
span { "Processing /music/Lossless/Pink Floyd..." }
|
||||
span { "45%" }
|
||||
}
|
||||
div class="w-full h-1.5 bg-black/40 rounded-full overflow-hidden border border-white/5" {
|
||||
div class="h-full bg-primary-500 w-[45%] relative overflow-hidden" {
|
||||
div class="absolute inset-0 w-full h-full bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent)] animate-[shimmer_2s_infinite]" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Cancel" }
|
||||
}
|
||||
}
|
||||
|
||||
// Queued Job
|
||||
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center opacity-75 grayscale-[30%]" {
|
||||
div class="w-12 h-12 rounded-full bg-slate-800 text-slate-400 flex items-center justify-center shrink-0 border border-white/10" {
|
||||
i class="ph ph-image-square text-2xl" {}
|
||||
}
|
||||
div class="flex-1 w-full" {
|
||||
div class="flex justify-between items-center" {
|
||||
div {
|
||||
h3 class="text-sm font-medium text-slate-200" { "Download Missing Artwork" }
|
||||
p class="text-xs text-slate-400 mt-0.5" { "Queued behind Library Scan" }
|
||||
}
|
||||
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-slate-800 text-slate-400 border border-white/10" { "Pending" }
|
||||
}
|
||||
}
|
||||
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Remove" }
|
||||
}
|
||||
}
|
||||
|
||||
// Completed Job
|
||||
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center" {
|
||||
div class="w-12 h-12 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center shrink-0 border border-emerald-500/20" {
|
||||
i class="ph-fill ph-check-circle text-2xl" {}
|
||||
}
|
||||
div class="flex-1 w-full" {
|
||||
div class="flex justify-between items-center" {
|
||||
div {
|
||||
h3 class="text-sm font-medium text-slate-200" { "Daily Database Backup" }
|
||||
p class="text-xs text-slate-400 mt-0.5" { "Completed in 1.4s at 03:00 AM" }
|
||||
}
|
||||
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" { "Success" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Failed Job
|
||||
div class="p-4 rounded-xl border border-rose-500/20 bg-rose-500/5 flex flex-col md:flex-row gap-4 items-center" {
|
||||
div class="w-12 h-12 rounded-full bg-rose-500/10 text-rose-400 flex items-center justify-center shrink-0 border border-rose-500/30" {
|
||||
i class="ph-fill ph-warning-circle text-2xl" {}
|
||||
}
|
||||
div class="flex-1 w-full" {
|
||||
div class="flex justify-between items-center" {
|
||||
div {
|
||||
h3 class="text-sm font-medium text-rose-200" { "Generate Waveforms" }
|
||||
p class="text-xs text-rose-400/80 mt-0.5" { "Failed: Error running FFmpeg command on Track14.flac" }
|
||||
}
|
||||
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-rose-500/20 text-rose-400 border border-rose-500/30" { "Failed" }
|
||||
}
|
||||
}
|
||||
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors border border-white/5" { "Retry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
style {
|
||||
r#"
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
"#
|
||||
}
|
||||
})
|
||||
}
|
||||
156
src/admin/views/library.rs
Normal file
156
src/admin/views/library.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn library() -> Markup {
|
||||
layout("Library Management", html! {
|
||||
div class="flex flex-col gap-6" {
|
||||
|
||||
// Library Paths
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-hard-drives text-primary-400" {} "Media Folders"
|
||||
}
|
||||
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-2" {
|
||||
i class="ph ph-plus" {} "Add Directory"
|
||||
}
|
||||
}
|
||||
div class="p-6 overflow-x-auto" {
|
||||
table class="w-full text-left" {
|
||||
thead class="text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
|
||||
tr {
|
||||
th class="pb-3 font-semibold" { "Path" }
|
||||
th class="pb-3 font-semibold w-32" { "Scan Mode" }
|
||||
th class="pb-3 font-semibold text-right" { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody class="text-sm divide-y divide-white/5" {
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
|
||||
i class="ph-fill ph-folder text-amber-500 text-lg" {}
|
||||
"/data/music/FLACs"
|
||||
}
|
||||
td class="py-4" {
|
||||
span class="px-2 py-1 rounded text-xs tracking-wide bg-blue-500/10 text-blue-400 border border-blue-500/20" { "Inotify Watch" }
|
||||
}
|
||||
td class="py-4 text-right space-x-2" {
|
||||
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
|
||||
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
|
||||
}
|
||||
}
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
|
||||
i class="ph-fill ph-folder text-amber-500 text-lg" {}
|
||||
"/mnt/external_hdd/MP3s"
|
||||
}
|
||||
td class="py-4" {
|
||||
span class="px-2 py-1 rounded text-xs tracking-wide bg-slate-800 text-slate-400 border border-white/10" { "Scheduled" }
|
||||
}
|
||||
td class="py-4 text-right space-x-2" {
|
||||
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
|
||||
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="grid grid-cols-1 lg:grid-cols-2 gap-6" {
|
||||
// Scan Controls
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-scan text-purple-400" {} "Scan Engine"
|
||||
}
|
||||
}
|
||||
div class="p-6 flex-1 space-y-6" {
|
||||
|
||||
div class="space-y-3" {
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600/20 hover:bg-primary-600/40 text-primary-300 transition-colors border border-primary-500/30 group" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-magnifying-glass text-xl group-hover:scale-110 transition-transform" {}
|
||||
div class="text-left" {
|
||||
p class="font-medium text-primary-100" { "Full Metadata Scan" }
|
||||
p class="text-xs text-primary-400/70" { "Scans all files and applies new tag modifications." }
|
||||
}
|
||||
}
|
||||
i class="ph ph-caret-right text-lg opacity-50" {}
|
||||
}
|
||||
|
||||
button class="w-full flex items-center justify-between p-4 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-300 transition-colors border border-emerald-500/30 group" {
|
||||
div class="flex items-center gap-3" {
|
||||
i class="ph ph-music-notes-plus text-xl group-hover:scale-110 transition-transform" {}
|
||||
div class="text-left" {
|
||||
p class="font-medium text-emerald-100" { "Quick Scan" }
|
||||
p class="text-xs text-emerald-400/70" { "Only scans for newly added or deleted files based on timestamps." }
|
||||
}
|
||||
}
|
||||
i class="ph ph-caret-right text-lg opacity-50" {}
|
||||
}
|
||||
}
|
||||
|
||||
hr class="border-white/5" {}
|
||||
|
||||
div class="space-y-4" {
|
||||
p class="text-sm font-medium text-slate-300 mb-2" { "Scan Scheduling" }
|
||||
label class="flex items-center gap-3 text-sm text-slate-400" {
|
||||
input type="checkbox" checked class="w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50" {}
|
||||
"Enable file system watchers (inotify) for instant updates"
|
||||
}
|
||||
div class="flex items-center gap-3" {
|
||||
label class="text-sm text-slate-400" { "Full scan interval:" }
|
||||
select class="bg-black/30 border border-white/10 rounded border-white/10 px-3 py-1 text-sm text-white outline-none focus:border-primary-500" {
|
||||
option value="0" { "Never" }
|
||||
option value="24" selected { "Daily (at 3 AM)" }
|
||||
option value="168" { "Weekly" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata Tools
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-wrench text-amber-400" {} "Maintenance Tools"
|
||||
}
|
||||
}
|
||||
div class="p-6 flex-1 space-y-4" {
|
||||
|
||||
div class="p-4 rounded-lg border border-rose-500/20 bg-rose-500/5 flex gap-4" {
|
||||
div class="mt-0.5" { i class="ph ph-warning-circle text-xl text-rose-400" {} }
|
||||
div {
|
||||
h3 class="text-sm font-medium text-rose-200" { "Clean Database Orphan Links" }
|
||||
p class="text-xs text-rose-400/70 mt-1 mb-3" { "Removes database entries for files that no longer exist on the filesystem. Use with caution." }
|
||||
button class="px-4 py-1.5 rounded bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-semibold uppercase tracking-wider transition-colors" { "Clean Orphans" }
|
||||
}
|
||||
}
|
||||
|
||||
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
|
||||
div class="mt-0.5" { i class="ph ph-image-square text-xl text-slate-400" {} }
|
||||
div {
|
||||
h3 class="text-sm font-medium text-slate-200" { "Rebuild Album Art Cache" }
|
||||
p class="text-xs text-slate-500 mt-1 mb-3" { "Forces regeneration of thumbnail images and clears missing artwork cache." }
|
||||
button class="px-4 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold uppercase tracking-wider transition-colors border border-white/5" { "Rebuild Art" }
|
||||
}
|
||||
}
|
||||
|
||||
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
|
||||
div class="mt-0.5" { i class="ph ph-waveform text-xl text-slate-400" {} }
|
||||
div {
|
||||
h3 class="text-sm font-medium text-slate-200" { "Generate Waveforms" }
|
||||
p class="text-xs text-slate-500 mt-1 mb-3 flex items-center gap-2" {
|
||||
"Pre-computes audio waveform files for faster sought-scrubbing. This is very CPU intensive."
|
||||
span class="px-1.5 py-0.5 bg-indigo-500/20 border border-indigo-500/30 text-[10px] uppercase font-bold text-indigo-400 rounded" {"Coming Soon"}
|
||||
}
|
||||
button disabled class="px-4 py-1.5 rounded bg-slate-800/50 text-slate-600 text-xs font-semibold uppercase tracking-wider pointer-events-none border border-white/5" { "Generate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
114
src/admin/views/login.rs
Normal file
114
src/admin/views/login.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
use axum::{Form, response::Redirect};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn login_form() -> Markup {
|
||||
render_login(None)
|
||||
}
|
||||
|
||||
pub async fn login_post(Form(payload): Form<LoginPayload>) -> Result<Redirect, Markup> {
|
||||
if payload.username == "admin" && payload.password == "admin" {
|
||||
Ok(Redirect::to("/admin"))
|
||||
} else {
|
||||
Err(render_login(Some("Invalid username or password.")))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login(error: Option<&str>) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
title { "SoundSonic Admin - Login" }
|
||||
script src="https://cdn.tailwindcss.com" {}
|
||||
script {
|
||||
(maud::PreEscaped(r##"
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
"##))
|
||||
}
|
||||
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
|
||||
script src="https://unpkg.com/@phosphor-icons/web" {}
|
||||
style {
|
||||
(maud::PreEscaped(r##"
|
||||
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
"##))
|
||||
}
|
||||
}
|
||||
body class="min-h-screen flex items-center justify-center relative overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
|
||||
// Background Glows
|
||||
div class="absolute top-[20%] left-[20%] w-[30%] h-[30%] rounded-full bg-primary-900/30 blur-[120px] pointer-events-none" {}
|
||||
div class="absolute bottom-[20%] right-[20%] w-[30%] h-[30%] rounded-full bg-blue-900/20 blur-[120px] pointer-events-none" {}
|
||||
|
||||
div class="w-full max-w-sm glass-card rounded-2xl p-8 z-10 relative" {
|
||||
|
||||
div class="flex flex-col items-center mb-8" {
|
||||
div class="w-16 h-16 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20 mb-4" {
|
||||
i class="ph-fill ph-waves text-3xl text-white" {}
|
||||
}
|
||||
h1 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic Admin" }
|
||||
p class="text-slate-400 text-sm mt-1" { "Sign in to access control center" }
|
||||
}
|
||||
|
||||
@if let Some(msg) = error {
|
||||
div class="bg-rose-500/10 border border-rose-500/20 rounded-lg p-3 mb-6 flex items-start gap-3" {
|
||||
i class="ph-fill ph-warning-circle text-rose-400 text-lg mt-0.5" {}
|
||||
p class="text-sm text-rose-200" { (msg) }
|
||||
}
|
||||
}
|
||||
|
||||
form action="/admin/login" method="POST" class="space-y-5" {
|
||||
div {
|
||||
label for="username" class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
|
||||
div class="relative" {
|
||||
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
|
||||
i class="ph ph-user text-slate-500 text-lg" {}
|
||||
}
|
||||
input type="text" name="username" id="username" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="admin" {}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
div class="flex justify-between items-center mb-1.5" {
|
||||
label for="password" class="block text-sm font-medium text-slate-300" { "Password" }
|
||||
}
|
||||
div class="relative" {
|
||||
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
|
||||
i class="ph ph-lock-key text-slate-500 text-lg" {}
|
||||
}
|
||||
input type="password" name="password" id="password" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="••••••••" {}
|
||||
}
|
||||
}
|
||||
|
||||
button type="submit" class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm shadow-primary-900/20 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors mt-2" {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/admin/views/logs.rs
Normal file
135
src/admin/views/logs.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn logs() -> Markup {
|
||||
layout("Logs & Diagnostics", html! {
|
||||
div class="flex flex-col gap-6 h-[calc(100vh-12rem)]" {
|
||||
|
||||
// Top Controls Row
|
||||
div class="flex flex-col md:flex-row gap-4" {
|
||||
// Filters
|
||||
div class="glass-card rounded-xl border border-white/5 p-4 flex-1 flex flex-wrap gap-4 items-center" {
|
||||
div class="flex items-center gap-2" {
|
||||
i class="ph ph-funnel text-slate-400" {}
|
||||
span class="text-sm font-medium text-slate-300" { "Filters:" }
|
||||
}
|
||||
|
||||
select class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-slate-300 focus:outline-none focus:border-primary-500 transition-all text-sm appearance-none min-w-[120px]" {
|
||||
option value="all" { "All Levels" }
|
||||
option value="info" { "INFO only" }
|
||||
option value="warn" { "WARN & Above" }
|
||||
option value="error" { "ERROR only" }
|
||||
}
|
||||
|
||||
input type="text" placeholder="Search logs..." class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary-500 transition-all text-sm min-w-[200px]" {}
|
||||
|
||||
div class="flex-1" {} // spacer
|
||||
|
||||
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
|
||||
i class="ph ph-download-simple" {} "Download Server Log"
|
||||
}
|
||||
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
|
||||
i class="ph ph-trash" {} "Clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layout Split: Log Viewer | Active Sessions
|
||||
div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 min-h-0" {
|
||||
|
||||
// Log Viewer Panel (takes 2 cols)
|
||||
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
|
||||
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
|
||||
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
|
||||
i class="ph ph-terminal-window text-emerald-400" {} "Live Tail"
|
||||
}
|
||||
div class="flex items-center gap-2" {
|
||||
span class="relative flex h-2 w-2" {
|
||||
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" {}
|
||||
span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" {}
|
||||
}
|
||||
span class="text-xs text-slate-500 font-medium tracking-wide uppercase" { "Auto-scrolling" }
|
||||
}
|
||||
}
|
||||
div class="flex-1 p-4 bg-[#0a0f18] text-sm font-mono overflow-y-auto" {
|
||||
div class="space-y-1" {
|
||||
(log_line("14:02:11", "INFO", "text-blue-400", "Starting Subsonic API listener on 0.0.0.0:3311"))
|
||||
(log_line("14:02:12", "INFO", "text-blue-400", "Connecting to SQLite database pool..."))
|
||||
(log_line("14:02:12", "INFO", "text-blue-400", "Running pending migrations..."))
|
||||
(log_line("14:05:43", "WARN", "text-amber-400", "Client 'DSub/5.4' requested unsupported downsample bitrates. Falling back to 128kbps."))
|
||||
(log_line("14:09:01", "ERROR", "text-rose-400", "Failed to lookup LastFM metadata for artist 'Unknown': API timeout"))
|
||||
(log_line("14:15:22", "INFO", "text-blue-400", "User 'admin' logged in from 192.168.1.5"))
|
||||
(log_line("14:15:24", "INFO", "text-blue-400", "GET /rest/ping.view [200 OK] 4ms"))
|
||||
(log_line("14:16:00", "INFO", "text-blue-400", "GET /rest/getIndexes.view [200 OK] 24ms"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Active Sessions Panel
|
||||
div class="glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
|
||||
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
|
||||
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
|
||||
i class="ph ph-users-three text-primary-400" {} "Active Sessions"
|
||||
}
|
||||
span class="bg-primary-500/20 text-primary-400 text-xs font-bold px-2 py-0.5 rounded-full" { "2" }
|
||||
}
|
||||
div class="flex-1 overflow-y-auto divide-y divide-white/5" {
|
||||
|
||||
// Session item
|
||||
div class="p-4 hover:bg-white/[0.02] transition-colors" {
|
||||
div class="flex justify-between items-start mb-2" {
|
||||
div class="flex items-center gap-2" {
|
||||
div class="w-2 h-2 rounded-full bg-emerald-500" {}
|
||||
span class="font-medium text-sm text-slate-200" { "admin" }
|
||||
}
|
||||
span class="text-xs text-slate-500" { "10m ago" }
|
||||
}
|
||||
div class="space-y-1 mb-3" {
|
||||
p class="text-xs text-slate-400 flex items-center gap-2" {
|
||||
i class="ph ph-laptop" {} "Web Browser (Chrome)"
|
||||
}
|
||||
p class="text-xs text-slate-400 font-mono" { "192.168.1.5" }
|
||||
}
|
||||
div class="flex gap-2" {
|
||||
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
|
||||
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
|
||||
}
|
||||
}
|
||||
|
||||
// Session item 2
|
||||
div class="p-4 hover:bg-white/[0.02] transition-colors" {
|
||||
div class="flex justify-between items-start mb-2" {
|
||||
div class="flex items-center gap-2" {
|
||||
div class="w-2 h-2 rounded-full bg-emerald-500" {}
|
||||
span class="font-medium text-sm text-slate-200" { "guest" }
|
||||
}
|
||||
span class="text-xs text-slate-500" { "Just now" }
|
||||
}
|
||||
div class="space-y-1 mb-3" {
|
||||
p class="text-xs text-slate-400 flex items-center gap-2" {
|
||||
i class="ph ph-device-mobile" {} "DSub / 5.4.3"
|
||||
}
|
||||
p class="text-xs text-slate-400 font-mono" { "10.0.0.42" }
|
||||
}
|
||||
div class="flex gap-2" {
|
||||
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
|
||||
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn log_line(time: &str, level: &str, level_color: &str, msg: &str) -> Markup {
|
||||
html! {
|
||||
div class="flex gap-3 py-0.5 hover:bg-white/5 rounded px-1 transition-colors" {
|
||||
span class="text-slate-500 shrink-0 w-20 text-xs" { (time) }
|
||||
span class=(format!("shrink-0 w-12 text-xs font-bold {}", level_color)) { (level) }
|
||||
span class="text-slate-300 break-all" { (msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/admin/views/mod.rs
Normal file
10
src/admin/views/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod about;
|
||||
pub mod login;
|
||||
pub mod clients;
|
||||
pub mod dashboard;
|
||||
pub mod jobs;
|
||||
pub mod library;
|
||||
pub mod logs;
|
||||
pub mod playback;
|
||||
pub mod settings;
|
||||
pub mod users;
|
||||
125
src/admin/views/playback.rs
Normal file
125
src/admin/views/playback.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn playback() -> Markup {
|
||||
layout("Playback & Streaming", html! {
|
||||
div class="max-w-4xl mx-auto space-y-6" {
|
||||
|
||||
// Transcoding Settings
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-waveform text-purple-400" {} "Transcoding Rules"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-6 form-group" {
|
||||
|
||||
label class="flex items-center gap-4 cursor-pointer" {
|
||||
div class="relative flex items-center" {
|
||||
input type="checkbox" checked class="peer sr-only" {}
|
||||
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-purple-500 peer-checked:border-purple-400 transition-all" {}
|
||||
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||
}
|
||||
span class="text-sm font-medium text-slate-300" { "Enable Server-side Transcoding" }
|
||||
}
|
||||
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Default Max Bitrate" }
|
||||
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||
option value="0" { "No limit (Original)" }
|
||||
option value="320" selected { "320 kbps" }
|
||||
option value="256" { "256 kbps" }
|
||||
option value="192" { "192 kbps" }
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Transcode Format Preference" }
|
||||
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||
option value="mp3" { "MP3 (Maximum Compatibility)" }
|
||||
option value="opus" selected { "Opus (Best Quality/Size ratio)" }
|
||||
option value="aac" { "AAC (Apple Devices)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming Settings
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-broadcast text-sky-400" {} "Streaming Buffers"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-6" {
|
||||
|
||||
div class="grid grid-cols-1 md:grid-cols-3 gap-6" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Chunk Size"
|
||||
span class="text-xs text-slate-500" { "MB" }
|
||||
}
|
||||
input type="number" value="1" min="1" max="10" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Buffer Threshold"
|
||||
span class="text-xs text-slate-500" { "Seconds" }
|
||||
}
|
||||
input type="number" value="10" min="5" max="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Preload Next Track"
|
||||
span class="text-xs text-slate-500" { "Seconds" }
|
||||
}
|
||||
input type="number" value="15" min="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API Compatibility
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-plugs-connected text-amber-400" {} "Subsonic API Compatibility"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-4" {
|
||||
div class="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 p-4 rounded-lg" {
|
||||
i class="ph ph-info text-amber-500 text-xl shrink-0" {}
|
||||
p class="text-sm text-amber-200/80" { "Changes here affect how third-party apps like DSub, Play:Sub, or Ultrasonic parse server responses." }
|
||||
}
|
||||
|
||||
label class="flex items-center gap-4 cursor-pointer mt-4" {
|
||||
div class="relative flex items-center" {
|
||||
input type="checkbox" class="peer sr-only" {}
|
||||
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
|
||||
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||
}
|
||||
span class="text-sm font-medium text-slate-300" { "Strict API Mode" }
|
||||
}
|
||||
p class="text-xs text-slate-500 ml-15 pl-1" { "When enabled, drops unsupported legacy fields from JSON responses. May break older clients." }
|
||||
|
||||
div class="mt-4" {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Reported API Version" }
|
||||
select class="w-full md:w-1/2 bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm appearance-none" {
|
||||
option value="1.16.1" selected { "OpenSubsonic 1.16.1 (Modern)" }
|
||||
option value="1.15.0" { "Subsonic 1.15.0 (Legacy Default)" }
|
||||
option value="1.13.0" { "Subsonic 1.13.0 (Old Clients)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="flex justify-end" {
|
||||
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||
i class="ph ph-floppy-disk text-lg" {} "Save Playback Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
120
src/admin/views/settings.rs
Normal file
120
src/admin/views/settings.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn settings() -> Markup {
|
||||
layout("System Settings", html! {
|
||||
div class="max-w-4xl mx-auto space-y-6" {
|
||||
|
||||
// Server Network Base Settings
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-globe text-primary-400" {} "Network & Domain"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-6" {
|
||||
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
div class="md:col-span-2" {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Public Server URL" }
|
||||
input type="url" value="https://music.home.arpa" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
|
||||
p class="text-xs text-slate-500 mt-1.5" { "Used for generating share links and podcast enclosures." }
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Bind Port" }
|
||||
input type="number" value="3311" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
|
||||
}
|
||||
|
||||
div class="flex items-end pb-2" {
|
||||
label class="flex items-center gap-4 cursor-pointer" {
|
||||
div class="relative flex items-center" {
|
||||
input type="checkbox" checked class="peer sr-only" {}
|
||||
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
|
||||
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||
}
|
||||
span class="text-sm font-medium text-slate-300" { "Trust Proxy Headers (X-Forwarded-For)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Security Settings
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-shield-check text-rose-400" {} "Security Limits"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-6" {
|
||||
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
div class="flex flex-col justify-center" {
|
||||
label class="flex items-center gap-4 cursor-pointer" {
|
||||
div class="relative flex items-center" {
|
||||
input type="checkbox" class="peer sr-only" {}
|
||||
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-rose-500 peer-checked:border-rose-400 transition-all" {}
|
||||
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||
}
|
||||
span class="text-sm font-medium text-slate-300" { "Allow Public Registration" }
|
||||
}
|
||||
p class="text-xs text-slate-500 ml-15 pl-1 mt-1" { "Users can self-provision accounts without an invite." }
|
||||
}
|
||||
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Session Timeout"
|
||||
span class="text-xs text-slate-500" { "Days" }
|
||||
}
|
||||
input type="number" value="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Failed Login Threshold" }
|
||||
input type="number" value="5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Lockout Duration"
|
||||
span class="text-xs text-slate-500" { "Minutes" }
|
||||
}
|
||||
input type="number" value="15" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Storage Locations
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||
i class="ph ph-database text-emerald-400" {} "Internal Storage"
|
||||
}
|
||||
}
|
||||
div class="p-6 space-y-6" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Temp Directory" }
|
||||
input type="text" value="/tmp/soundsonic" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all font-mono text-sm" {}
|
||||
p class="text-xs text-slate-500 mt-1.5" { "Used for uploading chunks, transcoding frames, and zipping downloads." }
|
||||
}
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Artwork Cache Size Limit"
|
||||
span class="text-xs text-slate-500" { "GB" }
|
||||
}
|
||||
input type="number" value="1" step="0.5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="flex justify-end gap-4" {
|
||||
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||
i class="ph ph-floppy-disk text-lg" {} "Save Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
243
src/admin/views/users.rs
Normal file
243
src/admin/views/users.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use maud::{html, Markup};
|
||||
use crate::admin::layout::layout;
|
||||
|
||||
pub async fn users() -> Markup {
|
||||
layout("Users Management", html! {
|
||||
div class="flex justify-between items-center mb-6" {
|
||||
div {
|
||||
h2 class="text-lg font-medium text-white" { "All Users" }
|
||||
p class="text-sm text-slate-400 mt-1" { "Manage server access, roles, and capabilities." }
|
||||
}
|
||||
a href="/admin/users/new" class="bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||
i class="ph ph-plus-circle text-lg" {}
|
||||
"Create User"
|
||||
}
|
||||
}
|
||||
|
||||
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||
div class="overflow-x-auto" {
|
||||
table class="w-full text-left border-collapse" {
|
||||
thead class="bg-black/30 text-xs uppercase tracking-wider text-slate-400" {
|
||||
tr {
|
||||
th class="px-6 py-4 font-semibold" { "User" }
|
||||
th class="px-6 py-4 font-semibold" { "Roles & Access" }
|
||||
th class="px-6 py-4 font-semibold" { "Last Seen" }
|
||||
th class="px-6 py-4 font-semibold w-24" { "Status" }
|
||||
th class="px-6 py-4 font-semibold text-right" { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-white/5 text-sm" {
|
||||
// Admin User Row
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="px-6 py-4" {
|
||||
div class="flex items-center gap-3" {
|
||||
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-xs font-bold shadow-lg shadow-primary-500/20" { "A" }
|
||||
div {
|
||||
div class="font-medium text-white flex items-center gap-2" {
|
||||
"admin"
|
||||
i class="ph-fill ph-check-circle text-emerald-400 text-xs" title="Verified" {}
|
||||
}
|
||||
div class="text-xs text-slate-500 mt-0.5" { "admin@local" }
|
||||
}
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4" {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 text-[10px] font-bold tracking-wider" { "ADMIN" }
|
||||
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
|
||||
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Download" }
|
||||
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Upload" }
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4 text-slate-400 text-xs" {
|
||||
"Active now"
|
||||
}
|
||||
td class="px-6 py-4" {
|
||||
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" {
|
||||
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {} "Active"
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
|
||||
a href="/admin/users/1/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
|
||||
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
|
||||
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Disable" { i class="ph ph-prohibit" {} }
|
||||
}
|
||||
}
|
||||
|
||||
// Guest User Row
|
||||
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||
td class="px-6 py-4" {
|
||||
div class="flex items-center gap-3" {
|
||||
div class="w-8 h-8 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-xs font-bold text-slate-400" { "G" }
|
||||
div {
|
||||
div class="font-medium text-white" { "guest" }
|
||||
}
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4" {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4 text-slate-400 text-xs" {
|
||||
"2 days ago"
|
||||
}
|
||||
td class="px-6 py-4" {
|
||||
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-slate-800 text-slate-400 border border-slate-700" {
|
||||
div class="w-1.5 h-1.5 rounded-full bg-slate-500" {} "Offline"
|
||||
}
|
||||
}
|
||||
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
|
||||
a href="/admin/users/2/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
|
||||
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
|
||||
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Delete" { i class="ph ph-trash" {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn user_create() -> Markup {
|
||||
user_form_layout("Create New User", true)
|
||||
}
|
||||
|
||||
pub async fn user_edit() -> Markup {
|
||||
user_form_layout("Edit User: admin", false)
|
||||
}
|
||||
|
||||
fn user_form_layout(title_text: &str, is_new: bool) -> Markup {
|
||||
layout(title_text, html! {
|
||||
div class="max-w-4xl mx-auto" {
|
||||
// Header actions
|
||||
div class="flex items-center gap-4 mb-6" {
|
||||
a href="/admin/users" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors border border-white/5" {
|
||||
i class="ph ph-arrow-left text-lg" {}
|
||||
}
|
||||
div {
|
||||
h2 class="text-xl font-medium text-white" { (title_text) }
|
||||
p class="text-sm text-slate-400 mt-1" { "Configure account credentials, limits, and server permissions." }
|
||||
}
|
||||
}
|
||||
|
||||
form class="space-y-6" {
|
||||
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||
|
||||
// Left Column: Credentials & Limits
|
||||
div class="space-y-6 flex flex-col" {
|
||||
div class="glass-card rounded-xl p-6 border border-white/5" {
|
||||
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Credentials" }
|
||||
div class="space-y-4" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
|
||||
input type="text" value=(if!is_new{"admin"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all font-mono text-sm" placeholder="e.g. music_lover" {}
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" {
|
||||
"Password "
|
||||
@if !is_new { span class="text-xs text-slate-500 font-normal ml-1" { "(Leave blank to keep unchanged)" } }
|
||||
}
|
||||
input type="password" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="••••••••" {}
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Email (Optional)" }
|
||||
input type="email" value=(if!is_new{"admin@local"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="user@domain.com" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="glass-card rounded-xl p-6 border border-white/5 flex-1" {
|
||||
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Limits & Quotas" }
|
||||
div class="space-y-4" {
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Max Audio Bitrate"
|
||||
span class="text-slate-500 font-normal text-xs" { "Kbps" }
|
||||
}
|
||||
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||
option value="0" { "Unlimited (Direct Stream)" }
|
||||
option value="320" { "320 kbps (High)" }
|
||||
option value="192" { "192 kbps (Standard)" }
|
||||
option value="128" { "128 kbps (Low)" }
|
||||
option value="64" { "64 kbps (Cellular)" }
|
||||
}
|
||||
}
|
||||
div {
|
||||
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||
"Storage Quota"
|
||||
span class="text-slate-500 font-normal text-xs" { "GB" }
|
||||
}
|
||||
input type="number" min="0" value="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" {}
|
||||
p class="text-xs text-slate-500 mt-2" { "Set to 0 for unlimited storage (if Upload is allowed)." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right Column: Permissions
|
||||
div class="glass-card rounded-xl p-6 border border-white/5 h-full" {
|
||||
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5 flex items-center justify-between" {
|
||||
"Permissions"
|
||||
i class="ph ph-shield-check text-lg text-primary-400" {}
|
||||
}
|
||||
|
||||
div class="space-y-4" {
|
||||
// Admin Toggle
|
||||
label class="flex items-start gap-4 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 cursor-pointer hover:bg-amber-500/20 transition-colors group" {
|
||||
div class="relative flex items-center" {
|
||||
input type="checkbox" checked[(!is_new)] class="peer sr-only" {}
|
||||
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-amber-500 peer-checked:border-amber-400 transition-all" {}
|
||||
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm shadow-black/50" {}
|
||||
}
|
||||
div class="flex-1" {
|
||||
p class="text-sm font-medium text-amber-500 group-hover:text-amber-400 transition-colors" { "Administrator Privileges" }
|
||||
p class="text-xs text-amber-500/70 mt-1" { "Grants full access to modify server settings, manage users, and view all system logs." }
|
||||
}
|
||||
}
|
||||
|
||||
hr class="border-white/5 my-4" {}
|
||||
|
||||
// Capability Toggles
|
||||
(capability_toggle("Stream Media", "Stream music and video", true, "ph-play-circle"))
|
||||
(capability_toggle("Download Files", "Download original media files", true, "ph-download-simple"))
|
||||
(capability_toggle("Upload Media", "Upload files and modify library files", !is_new, "ph-upload-simple"))
|
||||
(capability_toggle("Create Playlists", "Create, edit, and delete playlists", true, "ph-list-dashes"))
|
||||
(capability_toggle("Share Links", "Create public sharing links for media", !is_new, "ph-share-network"))
|
||||
(capability_toggle("Transcoding", "Allow server-side format conversion", true, "ph-waveform"))
|
||||
(capability_toggle("Folder Access", "See raw folder structure not just tags", true, "ph-folder"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Buttons
|
||||
div class="flex items-center justify-end gap-4 mt-8 pt-6 border-t border-white/5" {
|
||||
a href="/admin/users" class="px-5 py-2.5 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors" { "Cancel" }
|
||||
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||
i class="ph ph-floppy-disk text-lg" {}
|
||||
"Save User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capability_toggle(title: &str, desc: &str, checked_by_default: bool, icon: &str) -> Markup {
|
||||
html! {
|
||||
label class="flex items-start gap-4 p-3 rounded-lg border border-transparent hover:bg-white/5 hover:border-white/10 cursor-pointer transition-all" {
|
||||
div class="relative flex items-center shrink-0 mt-0.5" {
|
||||
input type="checkbox" checked class="peer w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50 appearance-none checked:bg-primary-500 checked:border-primary-500 transition-all relative overflow-hidden flex items-center justify-center
|
||||
after:content-[''] after:absolute after:w-2 after:h-2.5 after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:-translate-y-0.5 after:opacity-0 peer-checked:after:opacity-100" {}
|
||||
}
|
||||
div class="flex-1 flex gap-3" {
|
||||
i class=(format!("ph {} text-xl text-slate-400 mt-0.5", icon)) {}
|
||||
div {
|
||||
p class="text-sm font-medium text-slate-200" { (title) }
|
||||
p class="text-xs text-slate-500 mt-0.5" { (desc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/main.rs
17
src/main.rs
@@ -1,16 +1,19 @@
|
||||
mod admin;
|
||||
mod db;
|
||||
mod routes;
|
||||
mod schema;
|
||||
mod state;
|
||||
mod types;
|
||||
use std::env;
|
||||
|
||||
use axum::Router;
|
||||
use dotenvy::dotenv;
|
||||
use std::env;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::routes::system_router::system_routers;
|
||||
use crate::routes::user_management_router::user_management_routs;
|
||||
use crate::state::AppState;
|
||||
use crate::admin::routes::admin_router;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -18,9 +21,17 @@ async fn main() {
|
||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let state = AppState::new(&database_url)
|
||||
.unwrap_or_else(|_| panic!("Error creating pool for {}", database_url));
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "tower_http=debug,axum=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
let app = Router::new()
|
||||
.nest("/rest", system_routers().merge(user_management_routs()))
|
||||
.nest("/admin", admin_router())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3311").await.unwrap();
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
use crate::state::AppState;
|
||||
use crate::schema::users;
|
||||
use crate::types::types::OpenSubSonicResponses;
|
||||
use crate::{state::AppState, types::types::OpenSubsonicAuth};
|
||||
use axum::extract::State;
|
||||
use axum::{Json, Router, http::StatusCode, routing::get};
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
|
||||
pub fn system_routers() -> Router<AppState> {
|
||||
Router::new().route(
|
||||
"/getOpenSubsonicExtensions",
|
||||
get(get_opensubsonic_extentions).post(get_opensubsonic_extentions),
|
||||
Router::new()
|
||||
.route(
|
||||
"/getOpenSubsonicExtensions.view",
|
||||
get(get_opensubsonic_extensions).post(get_opensubsonic_extensions),
|
||||
)
|
||||
.route("/ping.view", get(ping).post(ping))
|
||||
.route("/license.view", get(license).post(license))
|
||||
}
|
||||
|
||||
async fn license(
|
||||
State(db_con): State<AppState>,
|
||||
user: OpenSubsonicAuth,
|
||||
) -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||
let conn = &mut db_con.pool.get().unwrap();
|
||||
let result = users::table
|
||||
.select(users::email)
|
||||
.filter(users::username.eq(user.username))
|
||||
.first::<String>(conn);
|
||||
match result {
|
||||
Ok(e) => (
|
||||
StatusCode::OK,
|
||||
Json(OpenSubSonicResponses::license_response(Some(e))),
|
||||
),
|
||||
Err(_) => (
|
||||
StatusCode::OK,
|
||||
Json(OpenSubSonicResponses::license_response(None)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ping(_user: OpenSubsonicAuth) -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response()),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_opensubsonic_extentions() -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||
async fn get_opensubsonic_extensions() -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(OpenSubSonicResponses::open_subsonic_extentions()),
|
||||
Json(OpenSubSonicResponses::open_subsonic_extensions()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ use axum::{
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, insert_into};
|
||||
|
||||
pub fn user_management_routs() -> Router<AppState> {
|
||||
let userm_routs = Router::new().route("/createUser.view", get(create_user).post(create_user));
|
||||
return userm_routs;
|
||||
Router::new().route("/createUser.view", get(create_user).post(create_user))
|
||||
}
|
||||
|
||||
fn check_if_user_exist(user_name: &String, db_con: &AppState) -> bool {
|
||||
@@ -38,7 +37,9 @@ async fn create_user(
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response()),
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::MissingParameter,
|
||||
)),
|
||||
);
|
||||
};
|
||||
if check_if_user_exist(¶ms.username, &db_con) {
|
||||
|
||||
@@ -10,3 +10,14 @@ pub struct OpenSubSonicExtension {
|
||||
pub struct OpenSubSonicExtensions {
|
||||
pub openSubsonicExtensions: Vec<OpenSubSonicExtension>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LicenseBody {
|
||||
pub valid: bool,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LicenseResponse {
|
||||
pub license: LicenseBody,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
use axum::{
|
||||
Json, RequestPartsExt,
|
||||
extract::{FromRequestParts, Query, RawForm},
|
||||
http::{StatusCode, request::Parts},
|
||||
middleware::{from_extractor, from_extractor_with_state},
|
||||
};
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::system::{OpenSubSonicExtension, OpenSubSonicExtensions};
|
||||
use crate::{
|
||||
schema::users,
|
||||
state::AppState,
|
||||
types::system::{LicenseBody, LicenseResponse, OpenSubSonicExtension, OpenSubSonicExtensions},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum OpenSubsonicErrorCode {
|
||||
@@ -47,6 +58,118 @@ impl OpenSubsonicErrorCode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OpenSubsonicBaseQuery {
|
||||
pub u: String,
|
||||
pub p: Option<String>,
|
||||
pub t: Option<String>,
|
||||
pub s: Option<String>,
|
||||
pub apiKey: Option<String>,
|
||||
pub v: String,
|
||||
pub c: String,
|
||||
pub f: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OpenSubsonicAuth {
|
||||
pub username: String,
|
||||
pub client: String,
|
||||
pub version: String,
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for OpenSubsonicAuth {
|
||||
type Rejection = (StatusCode, Json<OpenSubSonicResponses>);
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
// ---- 1. Parse query safely ----
|
||||
let Query(query) = Query::<OpenSubsonicBaseQuery>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::MissingParameter,
|
||||
)),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ---- 2. Require token + salt ----
|
||||
let (token, salt) = match (query.t.as_ref(), query.s.as_ref()) {
|
||||
(Some(t), Some(s)) => (t, s),
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::AuthMechanismNotSupported,
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 3. DB lookup (blocking -> spawn_blocking) ----
|
||||
let username = query.u.clone();
|
||||
let pool = state.pool.clone();
|
||||
|
||||
let password = tokio::task::spawn_blocking(
|
||||
move || -> Result<String, (StatusCode, Json<OpenSubSonicResponses>)> {
|
||||
let mut conn = pool.get().map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::Generic,
|
||||
)),
|
||||
)
|
||||
})?;
|
||||
|
||||
users::table
|
||||
.select(users::password)
|
||||
.filter(users::username.eq(username))
|
||||
.first::<String>(&mut conn)
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::WrongUsernameOrPassword,
|
||||
)),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::Generic,
|
||||
)),
|
||||
)
|
||||
})??;
|
||||
|
||||
// ---- 4. verify token ----
|
||||
let expected_token = format!("{:x}", md5::compute(format!("{}{}", password, salt)));
|
||||
|
||||
if &expected_token != token {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||
OpenSubsonicErrorCode::NotAuthorized,
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(OpenSubsonicAuth {
|
||||
username: query.u,
|
||||
client: query.c,
|
||||
version: query.v,
|
||||
format: query.f,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BaseResponse<T> {
|
||||
@@ -62,11 +185,17 @@ pub struct BaseResponse<T> {
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErrorResponse {
|
||||
struct ErrorResponseBody {
|
||||
code: u8,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErrorResponse {
|
||||
error: ErrorResponseBody,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum OpenSubSonicResponses {
|
||||
@@ -83,6 +212,10 @@ pub enum OpenSubSonicResponses {
|
||||
subsonic_response: BaseResponse<OpenSubSonicExtensions>,
|
||||
// openSubSonicExtensions: Vec<OpenSubSonicExtension>,
|
||||
},
|
||||
License {
|
||||
#[serde(rename = "subsonic-response")]
|
||||
subsonic_response: BaseResponse<LicenseResponse>,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpenSubSonicResponses {
|
||||
@@ -97,6 +230,27 @@ impl OpenSubSonicResponses {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn license_response(email: Option<String>) -> Self {
|
||||
match email {
|
||||
Some(_) => Self::License {
|
||||
subsonic_response: Self::base_response(Some(LicenseResponse {
|
||||
license: LicenseBody {
|
||||
valid: true,
|
||||
email: email,
|
||||
},
|
||||
})),
|
||||
},
|
||||
None => Self::License {
|
||||
subsonic_response: Self::base_response(Some(LicenseResponse {
|
||||
license: LicenseBody {
|
||||
valid: true,
|
||||
email: None,
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_subsonic_response_error(error_code: OpenSubsonicErrorCode) -> Self {
|
||||
Self::OpenSubSonicResponseError {
|
||||
subsonic_response: BaseResponse {
|
||||
@@ -106,8 +260,10 @@ impl OpenSubSonicResponses {
|
||||
server_version: "1.16.1".to_string(),
|
||||
open_subsonic: true,
|
||||
data: Some(ErrorResponse {
|
||||
code: error_code as u8,
|
||||
message: error_code.description().to_string(),
|
||||
error: ErrorResponseBody {
|
||||
code: error_code as u8,
|
||||
message: error_code.description().to_string(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -119,7 +275,7 @@ impl OpenSubSonicResponses {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_subsonic_extentions() -> Self {
|
||||
pub fn open_subsonic_extensions() -> Self {
|
||||
let mut extension: Vec<OpenSubSonicExtension> = Vec::new();
|
||||
extension.push(OpenSubSonicExtension {
|
||||
name: "apiKeyAuthentication".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user