Compare commits

..

3 Commits

Author SHA1 Message Date
vaibhav
3f33bcdf32 Feat: Implement Admin Dashboard UI
This commit introduces the foundational UI for the admin dashboard. It
includes:
- A new `maud`-based layout component for consistent page structure.
- A navigation sidebar and header.
- Basic views for dashboard overview, user management, library settings,
  and about page.
- Integration with Tailwind CSS and Phosphor Icons for styling.
- Added `maud` and `axum-core` as dependencies.
2026-03-15 05:38:48 +05:30
vaibhav
c862669b08 feat: Add tracing and md5 dependency
Adds the `tracing` and `md5` crates to the project, enabling enhanced
logging and cryptographic hashing capabilities. Includes `TraceLayer`
for HTTP request tracing and configures `tracing-subscriber` with
`EnvFilter`.
2026-02-22 02:44:58 +05:30
vaibhav
cae0136aaf Update README with current status and features
Clarify that the project implements *parts* of the OpenSubsonic API. Add
a section detailing the current status and explicitly list implemented
features. Update installation steps to include installing the Diesel
CLI. Add an example for the `createUser.view` endpoint.
2026-02-14 03:48:48 +05:30
22 changed files with 1952 additions and 69 deletions

182
Cargo.lock generated
View File

@@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -62,15 +71,15 @@ dependencies = [
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytes" name = "bytes"
@@ -80,9 +89,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.55" version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -131,9 +140,9 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" checksum = "2163a0e204a148662b6b6816d4b5d5668a5f2f8df498ccbd5cd0e864e78fecba"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
] ]
@@ -245,35 +254,35 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"pin-utils", "slab",
] ]
[[package]] [[package]]
@@ -386,9 +395,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.85" version = "0.3.87"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -402,9 +411,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.181" version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
@@ -431,12 +440,51 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -543,6 +591,18 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -572,6 +632,23 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "rsqlite-vfs" name = "rsqlite-vfs"
version = "0.1.0" version = "0.1.0"
@@ -700,6 +777,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@@ -723,9 +806,12 @@ dependencies = [
"axum", "axum",
"diesel", "diesel",
"dotenvy", "dotenvy",
"maud",
"md5",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tower-http",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -749,9 +835,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.114" version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -868,6 +954,22 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -918,19 +1020,23 @@ version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [ dependencies = [
"matchers",
"nu-ansi-term", "nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
] ]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.23" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "valuable" name = "valuable"
@@ -944,6 +1050,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@@ -952,9 +1064,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.108" version = "0.2.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -965,9 +1077,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.108" version = "0.2.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -975,9 +1087,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.108" version = "0.2.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -988,9 +1100,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.108" version = "0.2.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -1086,6 +1198,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.20" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -13,4 +13,7 @@ axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] } 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"] }

View File

@@ -1,18 +1,22 @@
# SoundSonic # SoundSonic
A Rust-based music streaming server implementing the OpenSubsonic API protocol. A Rust-based server implementing parts of the OpenSubsonic API protocol.
## Overview ## 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 ## Features
- **OpenSubsonic API Support**: Compatible with any client supporting the OpenSubsonic protocol - **OpenSubsonic Response Envelope**: Consistent `subsonic-response` JSON format
- **User Management**: Role-based access control with customizable user permissions - **OpenSubsonic Extensions Endpoint**: Exposes supported extensions via `getOpenSubsonicExtensions`
- **SQLite Database**: Lightweight local storage with Diesel ORM - **User Creation**: `createUser.view` endpoint with role/permission fields
- **RESTful API**: Clean, well-structured API endpoints - **SQLite + Diesel**: Lightweight local storage
- **Async Runtime**: Built on Tokio for high performance - **Axum + Tokio**: Async HTTP server runtime
## Technologies ## Technologies
@@ -25,18 +29,23 @@ SoundSonic is a lightweight music streaming server built with Rust that implemen
## Prerequisites ## Prerequisites
- Rust (latest stable version) - Rust (latest stable version)
- SQLite - SQLite (system library)
- Diesel CLI (for database migrations) - Diesel CLI (for database migrations)
## Installation ## Installation
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone <repository-url> git clone <your-repository-url>
cd soundsonic 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 ```bash
export DATABASE_URL=database.db export DATABASE_URL=database.db
``` ```
@@ -46,27 +55,37 @@ Or create a `.env` file:
DATABASE_URL=database.db DATABASE_URL=database.db
``` ```
3. Run database migrations: 4. Run database migrations:
```bash ```bash
diesel migration run diesel migration run
``` ```
4. Build and run the server: 5. Build and run the server:
```bash ```bash
cargo run 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 ## API Endpoints
All endpoints are nested under the `/rest` prefix.
### System ### System
- `GET/POST /rest/getOpenSubsonicExtensions` - Get supported OpenSubsonic extensions - `GET /rest/getOpenSubsonicExtensions`
- `POST /rest/getOpenSubsonicExtensions`
### User Management ### 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 ### Request/Response Format
@@ -137,7 +156,7 @@ diesel migration run
## License ## License
[Your License Here] MIT (see `LICENSE`).
## Contributing ## Contributing

150
src/admin/layout.rs Normal file
View 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
View File

@@ -0,0 +1,3 @@
pub mod layout;
pub mod routes;
pub mod views;

24
src/admin/routes.rs Normal file
View 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
View 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"
}
}
}
}
}
}
})
}

View 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"
}
}
}
}
}
}
}
}
})
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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) }
}
}
}
}
}

View File

@@ -1,16 +1,19 @@
mod admin;
mod db; mod db;
mod routes; mod routes;
mod schema; mod schema;
mod state; mod state;
mod types; mod types;
use std::env;
use axum::Router; use axum::Router;
use dotenvy::dotenv; 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::system_router::system_routers;
use crate::routes::user_management_router::user_management_routs; use crate::routes::user_management_router::user_management_routs;
use crate::state::AppState; use crate::state::AppState;
use crate::admin::routes::admin_router;
#[tokio::main] #[tokio::main]
async fn 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 database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let state = AppState::new(&database_url) let state = AppState::new(&database_url)
.unwrap_or_else(|_| panic!("Error creating pool for {}", 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() let app = Router::new()
.nest("/rest", system_routers().merge(user_management_routs())) .nest("/rest", system_routers().merge(user_management_routs()))
.nest("/admin", admin_router())
.layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3311").await.unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:3311").await.unwrap();

View File

@@ -1,17 +1,51 @@
use crate::state::AppState; use crate::schema::users;
use crate::types::types::OpenSubSonicResponses; 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 axum::{Json, Router, http::StatusCode, routing::get};
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
pub fn system_routers() -> Router<AppState> { pub fn system_routers() -> Router<AppState> {
Router::new().route( Router::new()
"/getOpenSubsonicExtensions", .route(
get(get_opensubsonic_extentions).post(get_opensubsonic_extentions), "/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, StatusCode::OK,
Json(OpenSubSonicResponses::open_subsonic_extentions()), Json(OpenSubSonicResponses::open_subsonic_extensions()),
) )
} }

View File

@@ -13,8 +13,7 @@ use axum::{
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, insert_into}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, insert_into};
pub fn user_management_routs() -> Router<AppState> { pub fn user_management_routs() -> Router<AppState> {
let userm_routs = Router::new().route("/createUser.view", get(create_user).post(create_user)); Router::new().route("/createUser.view", get(create_user).post(create_user))
return userm_routs;
} }
fn check_if_user_exist(user_name: &String, db_con: &AppState) -> bool { fn check_if_user_exist(user_name: &String, db_con: &AppState) -> bool {
@@ -38,7 +37,9 @@ async fn create_user(
} else { } else {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(OpenSubSonicResponses::open_subsonic_response()), Json(OpenSubSonicResponses::open_subsonic_response_error(
OpenSubsonicErrorCode::MissingParameter,
)),
); );
}; };
if check_if_user_exist(&params.username, &db_con) { if check_if_user_exist(&params.username, &db_con) {

View File

@@ -10,3 +10,14 @@ pub struct OpenSubSonicExtension {
pub struct OpenSubSonicExtensions { pub struct OpenSubSonicExtensions {
pub openSubsonicExtensions: Vec<OpenSubSonicExtension>, 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,
}

View File

@@ -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 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)] #[derive(Debug, Clone, Copy)]
pub enum OpenSubsonicErrorCode { 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)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BaseResponse<T> { pub struct BaseResponse<T> {
@@ -62,11 +185,17 @@ pub struct BaseResponse<T> {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ErrorResponse { struct ErrorResponseBody {
code: u8, code: u8,
message: String, message: String,
} }
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
error: ErrorResponseBody,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum OpenSubSonicResponses { pub enum OpenSubSonicResponses {
@@ -83,6 +212,10 @@ pub enum OpenSubSonicResponses {
subsonic_response: BaseResponse<OpenSubSonicExtensions>, subsonic_response: BaseResponse<OpenSubSonicExtensions>,
// openSubSonicExtensions: Vec<OpenSubSonicExtension>, // openSubSonicExtensions: Vec<OpenSubSonicExtension>,
}, },
License {
#[serde(rename = "subsonic-response")]
subsonic_response: BaseResponse<LicenseResponse>,
},
} }
impl OpenSubSonicResponses { 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 { pub fn open_subsonic_response_error(error_code: OpenSubsonicErrorCode) -> Self {
Self::OpenSubSonicResponseError { Self::OpenSubSonicResponseError {
subsonic_response: BaseResponse { subsonic_response: BaseResponse {
@@ -106,8 +260,10 @@ impl OpenSubSonicResponses {
server_version: "1.16.1".to_string(), server_version: "1.16.1".to_string(),
open_subsonic: true, open_subsonic: true,
data: Some(ErrorResponse { data: Some(ErrorResponse {
error: ErrorResponseBody {
code: error_code as u8, code: error_code as u8,
message: error_code.description().to_string(), 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(); let mut extension: Vec<OpenSubSonicExtension> = Vec::new();
extension.push(OpenSubSonicExtension { extension.push(OpenSubSonicExtension {
name: "apiKeyAuthentication".to_string(), name: "apiKeyAuthentication".to_string(),