Compare commits
6 Commits
a0c3f5b502
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f33bcdf32 | ||
|
|
c862669b08 | ||
|
|
cae0136aaf | ||
|
|
123a409e14 | ||
|
|
fcaf70b717 | ||
|
|
9bf9a2296e |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
1203
Cargo.lock
generated
Normal file
1203
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "soundsonic"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
diesel = { version = "2.3.6", features = ["sqlite", "r2d2", "returning_clauses_for_sqlite_3_35"] }
|
||||||
|
# build libsqlite3 as part of the build process
|
||||||
|
# uncomment this line if you run into setup issues
|
||||||
|
# libsqlite3-sys = { version = "0.36", features = ["bundled"] }
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
axum = "0.8.8"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
tokio = { version = "1.49.0", features = ["full"] }
|
||||||
|
tracing-subscriber = { 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"] }
|
||||||
169
README.md
169
README.md
@@ -1,3 +1,168 @@
|
|||||||
# soundsonic
|
# SoundSonic
|
||||||
|
|
||||||
open subsonic api implementation
|
A Rust-based server implementing parts of the OpenSubsonic API protocol.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SoundSonic is a lightweight server built with Rust that implements the [OpenSubsonic](https://opensubsonic.netlify.app/) API specification. The long-term goal is to support streaming a local music collection to OpenSubsonic-compatible clients.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This project is under active development. Only a small subset of the OpenSubsonic API is implemented today (see **API Endpoints**).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **OpenSubsonic Response Envelope**: Consistent `subsonic-response` JSON format
|
||||||
|
- **OpenSubsonic Extensions Endpoint**: Exposes supported extensions via `getOpenSubsonicExtensions`
|
||||||
|
- **User Creation**: `createUser.view` endpoint with role/permission fields
|
||||||
|
- **SQLite + Diesel**: Lightweight local storage
|
||||||
|
- **Axum + Tokio**: Async HTTP server runtime
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
- **Rust** (Edition 2024)
|
||||||
|
- **Axum** - Web framework
|
||||||
|
- **Tokio** - Async runtime
|
||||||
|
- **Diesel** - ORM with SQLite
|
||||||
|
- **Serde** - Serialization/deserialization
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (latest stable version)
|
||||||
|
- SQLite (system library)
|
||||||
|
- Diesel CLI (for database migrations)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <your-repository-url>
|
||||||
|
cd soundsonic
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install Diesel CLI (SQLite):
|
||||||
|
```bash
|
||||||
|
cargo install diesel_cli --no-default-features --features sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up the database URL:
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL=database.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a `.env` file:
|
||||||
|
```
|
||||||
|
DATABASE_URL=database.db
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run database migrations:
|
||||||
|
```bash
|
||||||
|
diesel migration run
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Build and run the server:
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://0.0.0.0:3311`.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints are nested under the `/rest` prefix.
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
- `GET /rest/getOpenSubsonicExtensions`
|
||||||
|
- `POST /rest/getOpenSubsonicExtensions`
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
All API responses follow the OpenSubsonic response format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subsonic-response": {
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.16.1",
|
||||||
|
"type": "SoundSonic",
|
||||||
|
"serverVersion": "1.16.1",
|
||||||
|
"openSubsonic": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
Users can have the following permissions:
|
||||||
|
|
||||||
|
- `adminRole` - Administrative privileges
|
||||||
|
- `streamRole` - Stream music
|
||||||
|
- `downloadRole` - Download music
|
||||||
|
- `uploadRole` - Upload music
|
||||||
|
- `playlistRole` - Manage playlists
|
||||||
|
- `coverArtRole` - Manage cover art
|
||||||
|
- `commentRole` - Add comments
|
||||||
|
- `podcastRole` - Access podcasts
|
||||||
|
- `shareRole` - Share content
|
||||||
|
- `jukeboxRole` - Jukebox control
|
||||||
|
- `videoConversionRole` - Video conversion
|
||||||
|
- `settingsRole` - Change settings
|
||||||
|
- `ldapAuthenticated` - LDAP authentication
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The server can be configured via environment variables:
|
||||||
|
|
||||||
|
- `DATABASE_URL` - Path to the SQLite database file (required)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running in development mode:
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for production:
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database schema changes:
|
||||||
|
```bash
|
||||||
|
diesel migration generate <migration_name>
|
||||||
|
diesel migration run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported OpenSubsonic Extensions
|
||||||
|
|
||||||
|
- `apiKeyAuthentication` - API key-based authentication
|
||||||
|
- `formPost` - Form-based POST requests
|
||||||
|
- `indexBasedQueue` - Index-based queue management
|
||||||
|
- `songLyrics` - Song lyrics support
|
||||||
|
- `transcodeOffset` - Transcoding offset support
|
||||||
|
- `transcoding` - Audio transcoding capabilities
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (see `LICENSE`).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [OpenSubsonic](https://opensubsonic.netlify.app/) - The API specification this server implements
|
||||||
|
- [Subsonic](http://www.subsonic.org/) - The original music streaming server
|
||||||
|
|||||||
9
diesel.toml
Normal file
9
diesel.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# For documentation on how to configure this file,
|
||||||
|
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||||
|
|
||||||
|
[print_schema]
|
||||||
|
file = "src/schema.rs"
|
||||||
|
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||||
|
|
||||||
|
[migrations_directory]
|
||||||
|
dir = "migrations"
|
||||||
0
migrations/.diesel_lock
Normal file
0
migrations/.diesel_lock
Normal file
0
migrations/.keep
Normal file
0
migrations/.keep
Normal file
4
migrations/2026-02-09-205759-0000_init/down.sql
Normal file
4
migrations/2026-02-09-205759-0000_init/down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
-- down.sql (Diesel)
|
||||||
|
DROP TABLE IF EXISTS user_music_folders;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
28
migrations/2026-02-09-205759-0000_init/up.sql
Normal file
28
migrations/2026-02-09-205759-0000_init/up.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Your SQL goes here
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
ldapAuthenticated BOOLEAN NOT NULL DEFAULT 0 CHECK (ldapAuthenticated IN (0,1)),
|
||||||
|
adminRole BOOLEAN NOT NULL DEFAULT 0 CHECK (adminRole IN (0,1)),
|
||||||
|
settingsRole BOOLEAN NOT NULL DEFAULT 1 CHECK (settingsRole IN (0,1)),
|
||||||
|
streamRole BOOLEAN NOT NULL DEFAULT 1 CHECK (streamRole IN (0,1)),
|
||||||
|
jukeboxRole BOOLEAN NOT NULL DEFAULT 0 CHECK (jukeboxRole IN (0,1)),
|
||||||
|
downloadRole BOOLEAN NOT NULL DEFAULT 0 CHECK (downloadRole IN (0,1)),
|
||||||
|
uploadRole BOOLEAN NOT NULL DEFAULT 0 CHECK (uploadRole IN (0,1)),
|
||||||
|
playlistRole BOOLEAN NOT NULL DEFAULT 0 CHECK (playlistRole IN (0,1)),
|
||||||
|
coverArtRole BOOLEAN NOT NULL DEFAULT 0 CHECK (coverArtRole IN (0,1)),
|
||||||
|
commentRole BOOLEAN NOT NULL DEFAULT 0 CHECK (commentRole IN (0,1)),
|
||||||
|
podcastRole BOOLEAN NOT NULL DEFAULT 0 CHECK (podcastRole IN (0,1)),
|
||||||
|
shareRole BOOLEAN NOT NULL DEFAULT 0 CHECK (shareRole IN (0,1)),
|
||||||
|
videoConversionRole BOOLEAN NOT NULL DEFAULT 0 CHECK (videoConversionRole IN (0,1))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_music_folders (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
music_folder_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, music_folder_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
edition = "2024"
|
||||||
150
src/admin/layout.rs
Normal file
150
src/admin/layout.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use maud::{html, Markup, DOCTYPE};
|
||||||
|
|
||||||
|
pub fn layout(title: &str, content: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
html lang="en" {
|
||||||
|
head {
|
||||||
|
meta charset="utf-8";
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
|
title { "SoundSonic Admin - " (title) }
|
||||||
|
script src="https://cdn.tailwindcss.com" {}
|
||||||
|
// Tailwind Config for custom colors/fonts if needed
|
||||||
|
script {
|
||||||
|
(maud::PreEscaped(r##"
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"##))
|
||||||
|
}
|
||||||
|
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
|
||||||
|
// Add Phosphor icons for beautiful lightweight UI icons
|
||||||
|
script src="https://unpkg.com/@phosphor-icons/web" {}
|
||||||
|
|
||||||
|
style {
|
||||||
|
(maud::PreEscaped(r##"
|
||||||
|
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
|
||||||
|
/* Glassmorphism utilities */
|
||||||
|
.glass {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(30, 41, 59, 0.4);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.nav-link { transition: all 0.2s ease-in-out; border-left: 3px solid transparent; }
|
||||||
|
.nav-link:hover { background-color: rgba(51, 65, 85, 0.5); color: #fff; border-left-color: #3b82f6; }
|
||||||
|
.nav-link.active { background-color: rgba(51, 65, 85, 0.8); color: #fff; border-left-color: #3b82f6; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Custom scrollbar for dark mode */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: #0f172a; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||||
|
"##))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body class="h-screen flex overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
|
||||||
|
// Sidebar
|
||||||
|
aside class="w-64 glass flex flex-col z-20 flex-shrink-0" {
|
||||||
|
div class="h-16 flex items-center px-6 font-bold text-xl tracking-tight border-b border-white/10 gap-2 text-white" {
|
||||||
|
i class="ph-fill ph-waves text-primary-500 text-2xl" {}
|
||||||
|
"SoundSonic"
|
||||||
|
}
|
||||||
|
nav class="flex-1 overflow-y-auto py-4 space-y-1" {
|
||||||
|
// Using simplistic path matching logic conceptually handled in UI. We'll just define the links.
|
||||||
|
a href="/admin" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-squares-four text-lg" {} "Dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Manage" }
|
||||||
|
a href="/admin/users" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-users text-lg" {} "Users & Roles"
|
||||||
|
}
|
||||||
|
a href="/admin/library" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-books text-lg" {} "Library"
|
||||||
|
}
|
||||||
|
a href="/admin/clients" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-devices text-lg" {} "API Clients"
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Server" }
|
||||||
|
a href="/admin/playback" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-play-circle text-lg" {} "Playback/Stream"
|
||||||
|
}
|
||||||
|
a href="/admin/settings" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-gear text-lg" {} "Settings"
|
||||||
|
}
|
||||||
|
a href="/admin/jobs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-arrows-clockwise text-lg" {} "Background Jobs"
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Diagnostics" }
|
||||||
|
a href="/admin/logs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-terminal-window text-lg" {} "Logs & Debug"
|
||||||
|
}
|
||||||
|
a href="/admin/about" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
|
||||||
|
i class="ph ph-info text-lg" {} "About"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-4 border-t border-white/10" {
|
||||||
|
div class="flex items-center gap-3 px-2" {
|
||||||
|
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-sm font-bold shadow-lg shadow-primary-500/20" {
|
||||||
|
"A"
|
||||||
|
}
|
||||||
|
div class="flex-1 min-w-0" {
|
||||||
|
p class="text-sm font-medium text-white truncate" { "Administrator" }
|
||||||
|
p class="text-xs text-slate-400 truncate" { "admin@local" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Content
|
||||||
|
main class="flex-1 flex flex-col min-w-0 overflow-hidden relative" {
|
||||||
|
// Decorative background glow
|
||||||
|
div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-primary-900/20 blur-[120px] pointer-events-none" {}
|
||||||
|
div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-900/10 blur-[120px] pointer-events-none" {}
|
||||||
|
|
||||||
|
header class="h-16 glass flex items-center justify-between px-8 z-10" {
|
||||||
|
h1 class="text-xl font-semibold text-white tracking-tight flex items-center gap-2" {
|
||||||
|
(title)
|
||||||
|
}
|
||||||
|
div class="flex items-center gap-4 text-slate-400" {
|
||||||
|
button class="hover:text-white transition-colors" title="Notifications" { i class="ph ph-bell text-xl" {} }
|
||||||
|
button class="hover:text-white transition-colors" title="Server Status" {
|
||||||
|
div class="relative" {
|
||||||
|
i class="ph ph-hard-drives text-xl" {}
|
||||||
|
span class="absolute top-0 right-0 w-2 h-2 bg-emerald-500 rounded-full border border-slate-900" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a href="/rest/ping.view" class="text-sm border border-slate-700 hover:bg-slate-800 px-3 py-1.5 rounded-md transition-colors flex items-center gap-2" {
|
||||||
|
i class="ph ph-sign-out" {} "Exit Panel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex-1 overflow-y-auto p-8 z-10" {
|
||||||
|
div class="max-w-7xl mx-auto space-y-6" {
|
||||||
|
(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/admin/mod.rs
Normal file
3
src/admin/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod layout;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod views;
|
||||||
24
src/admin/routes.rs
Normal file
24
src/admin/routes.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use axum::{routing::get, Router};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::admin::views::{
|
||||||
|
dashboard, users, library, playback, settings, logs, clients, jobs, about, login
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn admin_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/login", get(login::login_form).post(login::login_post))
|
||||||
|
.route("/", get(dashboard::index))
|
||||||
|
|
||||||
|
.route("/users", get(users::users))
|
||||||
|
.route("/users/new", get(users::user_create))
|
||||||
|
.route("/users/{id}/edit", get(users::user_edit))
|
||||||
|
|
||||||
|
.route("/library", get(library::library))
|
||||||
|
.route("/playback", get(playback::playback))
|
||||||
|
.route("/settings", get(settings::settings))
|
||||||
|
|
||||||
|
.route("/logs", get(logs::logs))
|
||||||
|
.route("/clients", get(clients::clients))
|
||||||
|
.route("/jobs", get(jobs::jobs))
|
||||||
|
.route("/about", get(about::about))
|
||||||
|
}
|
||||||
99
src/admin/views/about.rs
Normal file
99
src/admin/views/about.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn about() -> Markup {
|
||||||
|
layout("About & Diagnostics", html! {
|
||||||
|
div class="max-w-3xl mx-auto space-y-6" {
|
||||||
|
|
||||||
|
// Server Identity Card
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden text-center p-8 space-y-4 relative" {
|
||||||
|
div class="w-24 h-24 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 mx-auto flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20" {
|
||||||
|
i class="ph-fill ph-waves text-5xl text-white" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
h2 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic" }
|
||||||
|
p class="text-primary-400 font-mono text-sm mt-1" { "v0.1.0-alpha.5 (build 4f8a9b2)" }
|
||||||
|
}
|
||||||
|
p class="text-slate-400 text-sm max-w-lg mx-auto" {
|
||||||
|
"A blazing fast, lightweight Subsonic-compatible media server written in Rust."
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="flex justify-center gap-4 mt-6 pt-6 border-t border-white/5" {
|
||||||
|
a href="#" class="text-slate-400 hover:text-white transition-colors flex flex-col items-center gap-1" {
|
||||||
|
i class="ph ph-github-logo text-3xl" {}
|
||||||
|
span class="text-xs font-medium" { "GitHub" }
|
||||||
|
}
|
||||||
|
a href="#" class="text-slate-400 hover:text-primary-400 transition-colors flex flex-col items-center gap-1" {
|
||||||
|
i class="ph ph-bug text-3xl" {}
|
||||||
|
span class="text-xs font-medium" { "Report Issue" }
|
||||||
|
}
|
||||||
|
a href="#" class="text-slate-400 hover:text-indigo-400 transition-colors flex flex-col items-center gap-1" {
|
||||||
|
i class="ph ph-book-open text-3xl" {}
|
||||||
|
span class="text-xs font-medium" { "Docs" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
|
||||||
|
i class="ph ph-info" {} "System Details"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-0" {
|
||||||
|
table class="w-full text-sm" {
|
||||||
|
tbody class="divide-y divide-white/5" {
|
||||||
|
tr class="hover:bg-white/[0.02]" {
|
||||||
|
td class="py-3 px-6 text-slate-500" { "OS" }
|
||||||
|
td class="py-3 px-6 text-slate-300 font-mono text-right" { "Linux 6.8.0-generic" }
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02]" {
|
||||||
|
td class="py-3 px-6 text-slate-500" { "Architecture" }
|
||||||
|
td class="py-3 px-6 text-slate-300 font-mono text-right" { "x86_64" }
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02]" {
|
||||||
|
td class="py-3 px-6 text-slate-500" { "Rust Version" }
|
||||||
|
td class="py-3 px-6 text-slate-300 font-mono text-right" { "rustc 1.77.0" }
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02]" {
|
||||||
|
td class="py-3 px-6 text-slate-500" { "Database" }
|
||||||
|
td class="py-3 px-6 text-slate-300 font-mono text-right" { "SQLite 3.45.1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
|
||||||
|
i class="ph ph-plugs-connected" {} "API Supported Extensions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 text-sm" {
|
||||||
|
ul class="space-y-3" {
|
||||||
|
li class="flex items-center gap-3 text-slate-300" {
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400" {} "subsonic"
|
||||||
|
}
|
||||||
|
li class="flex items-center gap-3 text-slate-300" {
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400" {} "transcodeOffset"
|
||||||
|
}
|
||||||
|
li class="flex items-center gap-3 text-slate-300" {
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400" {} "podcast"
|
||||||
|
}
|
||||||
|
li class="flex items-center gap-3 text-slate-300" {
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400" {} "videoConversion"
|
||||||
|
}
|
||||||
|
li class="flex items-center gap-3 text-slate-300" {
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400" {} "playQueue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
77
src/admin/views/clients.rs
Normal file
77
src/admin/views/clients.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn clients() -> Markup {
|
||||||
|
layout("API Clients & Tokens", html! {
|
||||||
|
div class="flex flex-col gap-6" {
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-devices text-primary-400" {} "Connected Clients"
|
||||||
|
}
|
||||||
|
div class="flex gap-2" {
|
||||||
|
span class="px-3 py-1 rounded bg-slate-800 text-slate-400 border border-white/5 text-xs font-medium" { "Token Auth Enabled" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 overflow-x-auto" {
|
||||||
|
table class="w-full text-left" {
|
||||||
|
thead class="bg-transparent text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
|
||||||
|
tr {
|
||||||
|
th class="pb-3 font-semibold" { "Client Name" }
|
||||||
|
th class="pb-3 font-semibold" { "Version" }
|
||||||
|
th class="pb-3 font-semibold" { "Owner / User" }
|
||||||
|
th class="pb-3 font-semibold" { "Last Used" }
|
||||||
|
th class="pb-3 font-semibold text-right" { "Actions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody class="text-sm divide-y divide-white/5" {
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
|
||||||
|
i class="ph ph-device-mobile text-xl text-indigo-400" {}
|
||||||
|
"Ultrasonic"
|
||||||
|
}
|
||||||
|
td class="py-4 text-slate-400" { "3.0.0-git" }
|
||||||
|
td class="py-4 text-slate-300" { "admin" }
|
||||||
|
td class="py-4 text-slate-400" { "2 hours ago" }
|
||||||
|
td class="py-4 text-right" {
|
||||||
|
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||||
|
i class="ph ph-key" {} "Revoke Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
|
||||||
|
i class="ph ph-browser text-xl text-sky-400" {}
|
||||||
|
"WebPlayer"
|
||||||
|
}
|
||||||
|
td class="py-4 text-slate-400" { "1.0.0" }
|
||||||
|
td class="py-4 text-slate-300" { "admin" }
|
||||||
|
td class="py-4 text-slate-400" { "Right now" }
|
||||||
|
td class="py-4 text-right" {
|
||||||
|
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||||
|
i class="ph ph-key" {} "Revoke Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors text-slate-500" {
|
||||||
|
td class="py-4 font-medium flex items-center gap-3" {
|
||||||
|
i class="ph ph-car text-xl" {}
|
||||||
|
"Android Auto Sub"
|
||||||
|
}
|
||||||
|
td class="py-4" { "1.2" }
|
||||||
|
td class="py-4" { "guest" }
|
||||||
|
td class="py-4" { "6 months ago" }
|
||||||
|
td class="py-4 text-right" {
|
||||||
|
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
|
||||||
|
i class="ph ph-key" {} "Revoke Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
171
src/admin/views/dashboard.rs
Normal file
171
src/admin/views/dashboard.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn index() -> Markup {
|
||||||
|
layout("Dashboard Overview", html! {
|
||||||
|
// Top Stats Row
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" {
|
||||||
|
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||||
|
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||||
|
i class="ph-fill ph-music-notes text-6xl text-primary-500" {}
|
||||||
|
}
|
||||||
|
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Songs" }
|
||||||
|
p class="text-4xl font-bold text-white mb-1" { "42,891" }
|
||||||
|
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||||
|
i class="ph-bold ph-trend-up" {} "+124 this week"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||||
|
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||||
|
i class="ph-fill ph-vinyl-record text-6xl text-purple-500" {}
|
||||||
|
}
|
||||||
|
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Albums" }
|
||||||
|
p class="text-4xl font-bold text-white mb-1" { "3,402" }
|
||||||
|
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||||
|
i class="ph-bold ph-trend-up" {} "+12 this week"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||||
|
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||||
|
i class="ph-fill ph-users text-6xl text-amber-500" {}
|
||||||
|
}
|
||||||
|
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Users" }
|
||||||
|
p class="text-4xl font-bold text-white mb-1" { "128" }
|
||||||
|
p class="text-xs text-emerald-400 flex items-center gap-1" {
|
||||||
|
i class="ph-bold ph-trend-up" {} "+3 this week"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
|
||||||
|
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
|
||||||
|
i class="ph-fill ph-headphones text-6xl text-pink-500" {}
|
||||||
|
}
|
||||||
|
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Active Sessions" }
|
||||||
|
p class="text-4xl font-bold text-white flex items-center gap-3 mb-1" {
|
||||||
|
"14"
|
||||||
|
span class="relative flex h-3 w-3" {
|
||||||
|
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75" {}
|
||||||
|
span class="relative inline-flex rounded-full h-3 w-3 bg-pink-500" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p class="text-xs text-slate-400 flex items-center gap-1" {
|
||||||
|
"Right now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Health & Quick Actions
|
||||||
|
div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6" {
|
||||||
|
|
||||||
|
// Server Health
|
||||||
|
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-heartbeat text-primary-400" {} "Server Health"
|
||||||
|
}
|
||||||
|
span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5" {
|
||||||
|
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {}
|
||||||
|
"Online"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 grid grid-cols-2 md:grid-cols-4 gap-6" {
|
||||||
|
div {
|
||||||
|
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Uptime" }
|
||||||
|
p class="text-xl font-medium text-white" { "14d 6h 22m" }
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "CPU Usage" }
|
||||||
|
p class="text-xl font-medium text-white flex items-center gap-2" {
|
||||||
|
"12%"
|
||||||
|
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
|
||||||
|
div class="h-full bg-primary-500 w-[12%]" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "RAM Usage" }
|
||||||
|
p class="text-xl font-medium text-white flex items-center gap-2" {
|
||||||
|
"2.4 GB"
|
||||||
|
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
|
||||||
|
div class="h-full bg-amber-500 w-[45%]" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Last Scan" }
|
||||||
|
p class="text-xl font-medium text-white" { "2 mins ago" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Errors
|
||||||
|
div class="px-6 py-3 bg-black/30 border-t border-white/5 border-b border-white/5" {
|
||||||
|
p class="text-xs font-medium text-slate-400 uppercase tracking-wider" { "Recent Log Activity" }
|
||||||
|
}
|
||||||
|
div class="flex-1 p-6 space-y-3 overflow-y-auto max-h-[300px] font-mono text-sm" {
|
||||||
|
div class="flex gap-3 text-slate-300" {
|
||||||
|
span class="text-slate-500 shrink-0" { "10:42:01 AM" }
|
||||||
|
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
|
||||||
|
span class="truncate" { "Library scan completed in 4.2s." }
|
||||||
|
}
|
||||||
|
div class="flex gap-3 text-slate-300" {
|
||||||
|
span class="text-slate-500 shrink-0" { "10:39:15 AM" }
|
||||||
|
span class="text-amber-400 shrink-0 w-12" { "WARN " }
|
||||||
|
span class="truncate" { "Failed to resolve cover art for /music/Unknown/Track01.mp3" }
|
||||||
|
}
|
||||||
|
div class="flex gap-3 text-slate-300" {
|
||||||
|
span class="text-slate-500 shrink-0" { "09:12:33 AM" }
|
||||||
|
span class="text-rose-400 shrink-0 w-12" { "ERROR" }
|
||||||
|
span class="truncate" { "Database connection timeout occurred during sync." }
|
||||||
|
}
|
||||||
|
div class="flex gap-3 text-slate-300" {
|
||||||
|
span class="text-slate-500 shrink-0" { "08:00:00 AM" }
|
||||||
|
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
|
||||||
|
span class="truncate" { "Daily backup completed successfully." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-lightning text-amber-400" {} "Quick Actions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-4" {
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600 hover:bg-primary-500 text-white transition-colors group shadow-lg shadow-primary-900/20" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-arrows-clockwise text-xl group-hover:rotate-180 transition-transform duration-500" {}
|
||||||
|
span class="font-medium" { "Start Library Scan" }
|
||||||
|
}
|
||||||
|
i class="ph ph-caret-right text-slate-300" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-stop-circle text-xl text-rose-400" {}
|
||||||
|
span class="font-medium inline-block" { "Stop Current Scan" }
|
||||||
|
}
|
||||||
|
span class="text-xs bg-black/30 px-2 py-1 rounded text-slate-400" { "Idle" }
|
||||||
|
}
|
||||||
|
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-broom text-xl text-amber-400" {}
|
||||||
|
span class="font-medium inline-block" { "Clear Image Cache" }
|
||||||
|
}
|
||||||
|
span class="text-xs text-slate-400" { "442 MB" }
|
||||||
|
}
|
||||||
|
|
||||||
|
hr class="border-white/5 my-2" {}
|
||||||
|
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-rose-900/40 hover:bg-rose-900/80 text-rose-200 transition-colors border border-rose-500/20" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-power text-xl" {}
|
||||||
|
span class="font-medium inline-block" { "Restart Server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
109
src/admin/views/jobs.rs
Normal file
109
src/admin/views/jobs.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn jobs() -> Markup {
|
||||||
|
layout("Background Jobs", html! {
|
||||||
|
div class="flex justify-between items-center mb-6" {
|
||||||
|
div {
|
||||||
|
h2 class="text-lg font-medium text-white" { "Job Queue" }
|
||||||
|
p class="text-sm text-slate-400 mt-1" { "Monitor long-running tasks and scanners." }
|
||||||
|
}
|
||||||
|
button class="text-slate-300 hover:text-white transition-colors bg-black/30 p-2 rounded-lg border border-white/5 flex items-center gap-2" title="Refresh" {
|
||||||
|
i class="ph ph-arrows-clockwise text-lg" {} "Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="p-6 space-y-4" {
|
||||||
|
|
||||||
|
// Active Job
|
||||||
|
div class="p-4 rounded-xl border border-primary-500/30 bg-primary-900/10 flex flex-col md:flex-row gap-4 items-center" {
|
||||||
|
div class="w-12 h-12 rounded-full bg-primary-500/20 text-primary-400 flex items-center justify-center shrink-0 border border-primary-500/30" {
|
||||||
|
i class="ph ph-magnifying-glass text-2xl animate-pulse" {}
|
||||||
|
}
|
||||||
|
div class="flex-1 w-full" {
|
||||||
|
div class="flex justify-between items-center mb-2" {
|
||||||
|
h3 class="text-sm font-medium text-primary-200" { "Library Metadata Rescan" }
|
||||||
|
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-primary-500/20 text-primary-400 border border-primary-500/30" { "Running" }
|
||||||
|
}
|
||||||
|
div class="flex justify-between text-xs text-primary-400/80 mb-1" {
|
||||||
|
span { "Processing /music/Lossless/Pink Floyd..." }
|
||||||
|
span { "45%" }
|
||||||
|
}
|
||||||
|
div class="w-full h-1.5 bg-black/40 rounded-full overflow-hidden border border-white/5" {
|
||||||
|
div class="h-full bg-primary-500 w-[45%] relative overflow-hidden" {
|
||||||
|
div class="absolute inset-0 w-full h-full bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent)] animate-[shimmer_2s_infinite]" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||||
|
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Cancel" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queued Job
|
||||||
|
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center opacity-75 grayscale-[30%]" {
|
||||||
|
div class="w-12 h-12 rounded-full bg-slate-800 text-slate-400 flex items-center justify-center shrink-0 border border-white/10" {
|
||||||
|
i class="ph ph-image-square text-2xl" {}
|
||||||
|
}
|
||||||
|
div class="flex-1 w-full" {
|
||||||
|
div class="flex justify-between items-center" {
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-slate-200" { "Download Missing Artwork" }
|
||||||
|
p class="text-xs text-slate-400 mt-0.5" { "Queued behind Library Scan" }
|
||||||
|
}
|
||||||
|
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-slate-800 text-slate-400 border border-white/10" { "Pending" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||||
|
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Remove" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed Job
|
||||||
|
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center" {
|
||||||
|
div class="w-12 h-12 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center shrink-0 border border-emerald-500/20" {
|
||||||
|
i class="ph-fill ph-check-circle text-2xl" {}
|
||||||
|
}
|
||||||
|
div class="flex-1 w-full" {
|
||||||
|
div class="flex justify-between items-center" {
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-slate-200" { "Daily Database Backup" }
|
||||||
|
p class="text-xs text-slate-400 mt-0.5" { "Completed in 1.4s at 03:00 AM" }
|
||||||
|
}
|
||||||
|
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" { "Success" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed Job
|
||||||
|
div class="p-4 rounded-xl border border-rose-500/20 bg-rose-500/5 flex flex-col md:flex-row gap-4 items-center" {
|
||||||
|
div class="w-12 h-12 rounded-full bg-rose-500/10 text-rose-400 flex items-center justify-center shrink-0 border border-rose-500/30" {
|
||||||
|
i class="ph-fill ph-warning-circle text-2xl" {}
|
||||||
|
}
|
||||||
|
div class="flex-1 w-full" {
|
||||||
|
div class="flex justify-between items-center" {
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-rose-200" { "Generate Waveforms" }
|
||||||
|
p class="text-xs text-rose-400/80 mt-0.5" { "Failed: Error running FFmpeg command on Track14.flac" }
|
||||||
|
}
|
||||||
|
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-rose-500/20 text-rose-400 border border-rose-500/30" { "Failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
|
||||||
|
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors border border-white/5" { "Retry" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
r#"
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
156
src/admin/views/library.rs
Normal file
156
src/admin/views/library.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn library() -> Markup {
|
||||||
|
layout("Library Management", html! {
|
||||||
|
div class="flex flex-col gap-6" {
|
||||||
|
|
||||||
|
// Library Paths
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-hard-drives text-primary-400" {} "Media Folders"
|
||||||
|
}
|
||||||
|
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-2" {
|
||||||
|
i class="ph ph-plus" {} "Add Directory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 overflow-x-auto" {
|
||||||
|
table class="w-full text-left" {
|
||||||
|
thead class="text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
|
||||||
|
tr {
|
||||||
|
th class="pb-3 font-semibold" { "Path" }
|
||||||
|
th class="pb-3 font-semibold w-32" { "Scan Mode" }
|
||||||
|
th class="pb-3 font-semibold text-right" { "Actions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody class="text-sm divide-y divide-white/5" {
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
|
||||||
|
i class="ph-fill ph-folder text-amber-500 text-lg" {}
|
||||||
|
"/data/music/FLACs"
|
||||||
|
}
|
||||||
|
td class="py-4" {
|
||||||
|
span class="px-2 py-1 rounded text-xs tracking-wide bg-blue-500/10 text-blue-400 border border-blue-500/20" { "Inotify Watch" }
|
||||||
|
}
|
||||||
|
td class="py-4 text-right space-x-2" {
|
||||||
|
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
|
||||||
|
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
|
||||||
|
i class="ph-fill ph-folder text-amber-500 text-lg" {}
|
||||||
|
"/mnt/external_hdd/MP3s"
|
||||||
|
}
|
||||||
|
td class="py-4" {
|
||||||
|
span class="px-2 py-1 rounded text-xs tracking-wide bg-slate-800 text-slate-400 border border-white/10" { "Scheduled" }
|
||||||
|
}
|
||||||
|
td class="py-4 text-right space-x-2" {
|
||||||
|
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
|
||||||
|
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="grid grid-cols-1 lg:grid-cols-2 gap-6" {
|
||||||
|
// Scan Controls
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-scan text-purple-400" {} "Scan Engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 flex-1 space-y-6" {
|
||||||
|
|
||||||
|
div class="space-y-3" {
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600/20 hover:bg-primary-600/40 text-primary-300 transition-colors border border-primary-500/30 group" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-magnifying-glass text-xl group-hover:scale-110 transition-transform" {}
|
||||||
|
div class="text-left" {
|
||||||
|
p class="font-medium text-primary-100" { "Full Metadata Scan" }
|
||||||
|
p class="text-xs text-primary-400/70" { "Scans all files and applies new tag modifications." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i class="ph ph-caret-right text-lg opacity-50" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
button class="w-full flex items-center justify-between p-4 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-300 transition-colors border border-emerald-500/30 group" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
i class="ph ph-music-notes-plus text-xl group-hover:scale-110 transition-transform" {}
|
||||||
|
div class="text-left" {
|
||||||
|
p class="font-medium text-emerald-100" { "Quick Scan" }
|
||||||
|
p class="text-xs text-emerald-400/70" { "Only scans for newly added or deleted files based on timestamps." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i class="ph ph-caret-right text-lg opacity-50" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr class="border-white/5" {}
|
||||||
|
|
||||||
|
div class="space-y-4" {
|
||||||
|
p class="text-sm font-medium text-slate-300 mb-2" { "Scan Scheduling" }
|
||||||
|
label class="flex items-center gap-3 text-sm text-slate-400" {
|
||||||
|
input type="checkbox" checked class="w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50" {}
|
||||||
|
"Enable file system watchers (inotify) for instant updates"
|
||||||
|
}
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
label class="text-sm text-slate-400" { "Full scan interval:" }
|
||||||
|
select class="bg-black/30 border border-white/10 rounded border-white/10 px-3 py-1 text-sm text-white outline-none focus:border-primary-500" {
|
||||||
|
option value="0" { "Never" }
|
||||||
|
option value="24" selected { "Daily (at 3 AM)" }
|
||||||
|
option value="168" { "Weekly" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata Tools
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-wrench text-amber-400" {} "Maintenance Tools"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 flex-1 space-y-4" {
|
||||||
|
|
||||||
|
div class="p-4 rounded-lg border border-rose-500/20 bg-rose-500/5 flex gap-4" {
|
||||||
|
div class="mt-0.5" { i class="ph ph-warning-circle text-xl text-rose-400" {} }
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-rose-200" { "Clean Database Orphan Links" }
|
||||||
|
p class="text-xs text-rose-400/70 mt-1 mb-3" { "Removes database entries for files that no longer exist on the filesystem. Use with caution." }
|
||||||
|
button class="px-4 py-1.5 rounded bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-semibold uppercase tracking-wider transition-colors" { "Clean Orphans" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
|
||||||
|
div class="mt-0.5" { i class="ph ph-image-square text-xl text-slate-400" {} }
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-slate-200" { "Rebuild Album Art Cache" }
|
||||||
|
p class="text-xs text-slate-500 mt-1 mb-3" { "Forces regeneration of thumbnail images and clears missing artwork cache." }
|
||||||
|
button class="px-4 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold uppercase tracking-wider transition-colors border border-white/5" { "Rebuild Art" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
|
||||||
|
div class="mt-0.5" { i class="ph ph-waveform text-xl text-slate-400" {} }
|
||||||
|
div {
|
||||||
|
h3 class="text-sm font-medium text-slate-200" { "Generate Waveforms" }
|
||||||
|
p class="text-xs text-slate-500 mt-1 mb-3 flex items-center gap-2" {
|
||||||
|
"Pre-computes audio waveform files for faster sought-scrubbing. This is very CPU intensive."
|
||||||
|
span class="px-1.5 py-0.5 bg-indigo-500/20 border border-indigo-500/30 text-[10px] uppercase font-bold text-indigo-400 rounded" {"Coming Soon"}
|
||||||
|
}
|
||||||
|
button disabled class="px-4 py-1.5 rounded bg-slate-800/50 text-slate-600 text-xs font-semibold uppercase tracking-wider pointer-events-none border border-white/5" { "Generate" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
114
src/admin/views/login.rs
Normal file
114
src/admin/views/login.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
use maud::{html, Markup, DOCTYPE};
|
||||||
|
use axum::{Form, response::Redirect};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_form() -> Markup {
|
||||||
|
render_login(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_post(Form(payload): Form<LoginPayload>) -> Result<Redirect, Markup> {
|
||||||
|
if payload.username == "admin" && payload.password == "admin" {
|
||||||
|
Ok(Redirect::to("/admin"))
|
||||||
|
} else {
|
||||||
|
Err(render_login(Some("Invalid username or password.")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_login(error: Option<&str>) -> Markup {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
html lang="en" {
|
||||||
|
head {
|
||||||
|
meta charset="utf-8";
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
|
title { "SoundSonic Admin - Login" }
|
||||||
|
script src="https://cdn.tailwindcss.com" {}
|
||||||
|
script {
|
||||||
|
(maud::PreEscaped(r##"
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
|
||||||
|
},
|
||||||
|
fontFamily: { sans: ['Inter', 'sans-serif'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"##))
|
||||||
|
}
|
||||||
|
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
|
||||||
|
script src="https://unpkg.com/@phosphor-icons/web" {}
|
||||||
|
style {
|
||||||
|
(maud::PreEscaped(r##"
|
||||||
|
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
"##))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body class="min-h-screen flex items-center justify-center relative overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
|
||||||
|
// Background Glows
|
||||||
|
div class="absolute top-[20%] left-[20%] w-[30%] h-[30%] rounded-full bg-primary-900/30 blur-[120px] pointer-events-none" {}
|
||||||
|
div class="absolute bottom-[20%] right-[20%] w-[30%] h-[30%] rounded-full bg-blue-900/20 blur-[120px] pointer-events-none" {}
|
||||||
|
|
||||||
|
div class="w-full max-w-sm glass-card rounded-2xl p-8 z-10 relative" {
|
||||||
|
|
||||||
|
div class="flex flex-col items-center mb-8" {
|
||||||
|
div class="w-16 h-16 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20 mb-4" {
|
||||||
|
i class="ph-fill ph-waves text-3xl text-white" {}
|
||||||
|
}
|
||||||
|
h1 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic Admin" }
|
||||||
|
p class="text-slate-400 text-sm mt-1" { "Sign in to access control center" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@if let Some(msg) = error {
|
||||||
|
div class="bg-rose-500/10 border border-rose-500/20 rounded-lg p-3 mb-6 flex items-start gap-3" {
|
||||||
|
i class="ph-fill ph-warning-circle text-rose-400 text-lg mt-0.5" {}
|
||||||
|
p class="text-sm text-rose-200" { (msg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form action="/admin/login" method="POST" class="space-y-5" {
|
||||||
|
div {
|
||||||
|
label for="username" class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
|
||||||
|
div class="relative" {
|
||||||
|
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
|
||||||
|
i class="ph ph-user text-slate-500 text-lg" {}
|
||||||
|
}
|
||||||
|
input type="text" name="username" id="username" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="admin" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
div class="flex justify-between items-center mb-1.5" {
|
||||||
|
label for="password" class="block text-sm font-medium text-slate-300" { "Password" }
|
||||||
|
}
|
||||||
|
div class="relative" {
|
||||||
|
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
|
||||||
|
i class="ph ph-lock-key text-slate-500 text-lg" {}
|
||||||
|
}
|
||||||
|
input type="password" name="password" id="password" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="••••••••" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button type="submit" class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm shadow-primary-900/20 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors mt-2" {
|
||||||
|
"Sign In"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/admin/views/logs.rs
Normal file
135
src/admin/views/logs.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn logs() -> Markup {
|
||||||
|
layout("Logs & Diagnostics", html! {
|
||||||
|
div class="flex flex-col gap-6 h-[calc(100vh-12rem)]" {
|
||||||
|
|
||||||
|
// Top Controls Row
|
||||||
|
div class="flex flex-col md:flex-row gap-4" {
|
||||||
|
// Filters
|
||||||
|
div class="glass-card rounded-xl border border-white/5 p-4 flex-1 flex flex-wrap gap-4 items-center" {
|
||||||
|
div class="flex items-center gap-2" {
|
||||||
|
i class="ph ph-funnel text-slate-400" {}
|
||||||
|
span class="text-sm font-medium text-slate-300" { "Filters:" }
|
||||||
|
}
|
||||||
|
|
||||||
|
select class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-slate-300 focus:outline-none focus:border-primary-500 transition-all text-sm appearance-none min-w-[120px]" {
|
||||||
|
option value="all" { "All Levels" }
|
||||||
|
option value="info" { "INFO only" }
|
||||||
|
option value="warn" { "WARN & Above" }
|
||||||
|
option value="error" { "ERROR only" }
|
||||||
|
}
|
||||||
|
|
||||||
|
input type="text" placeholder="Search logs..." class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary-500 transition-all text-sm min-w-[200px]" {}
|
||||||
|
|
||||||
|
div class="flex-1" {} // spacer
|
||||||
|
|
||||||
|
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
|
||||||
|
i class="ph ph-download-simple" {} "Download Server Log"
|
||||||
|
}
|
||||||
|
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
|
||||||
|
i class="ph ph-trash" {} "Clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout Split: Log Viewer | Active Sessions
|
||||||
|
div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 min-h-0" {
|
||||||
|
|
||||||
|
// Log Viewer Panel (takes 2 cols)
|
||||||
|
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
|
||||||
|
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
|
||||||
|
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
|
||||||
|
i class="ph ph-terminal-window text-emerald-400" {} "Live Tail"
|
||||||
|
}
|
||||||
|
div class="flex items-center gap-2" {
|
||||||
|
span class="relative flex h-2 w-2" {
|
||||||
|
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" {}
|
||||||
|
span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" {}
|
||||||
|
}
|
||||||
|
span class="text-xs text-slate-500 font-medium tracking-wide uppercase" { "Auto-scrolling" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex-1 p-4 bg-[#0a0f18] text-sm font-mono overflow-y-auto" {
|
||||||
|
div class="space-y-1" {
|
||||||
|
(log_line("14:02:11", "INFO", "text-blue-400", "Starting Subsonic API listener on 0.0.0.0:3311"))
|
||||||
|
(log_line("14:02:12", "INFO", "text-blue-400", "Connecting to SQLite database pool..."))
|
||||||
|
(log_line("14:02:12", "INFO", "text-blue-400", "Running pending migrations..."))
|
||||||
|
(log_line("14:05:43", "WARN", "text-amber-400", "Client 'DSub/5.4' requested unsupported downsample bitrates. Falling back to 128kbps."))
|
||||||
|
(log_line("14:09:01", "ERROR", "text-rose-400", "Failed to lookup LastFM metadata for artist 'Unknown': API timeout"))
|
||||||
|
(log_line("14:15:22", "INFO", "text-blue-400", "User 'admin' logged in from 192.168.1.5"))
|
||||||
|
(log_line("14:15:24", "INFO", "text-blue-400", "GET /rest/ping.view [200 OK] 4ms"))
|
||||||
|
(log_line("14:16:00", "INFO", "text-blue-400", "GET /rest/getIndexes.view [200 OK] 24ms"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active Sessions Panel
|
||||||
|
div class="glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
|
||||||
|
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
|
||||||
|
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
|
||||||
|
i class="ph ph-users-three text-primary-400" {} "Active Sessions"
|
||||||
|
}
|
||||||
|
span class="bg-primary-500/20 text-primary-400 text-xs font-bold px-2 py-0.5 rounded-full" { "2" }
|
||||||
|
}
|
||||||
|
div class="flex-1 overflow-y-auto divide-y divide-white/5" {
|
||||||
|
|
||||||
|
// Session item
|
||||||
|
div class="p-4 hover:bg-white/[0.02] transition-colors" {
|
||||||
|
div class="flex justify-between items-start mb-2" {
|
||||||
|
div class="flex items-center gap-2" {
|
||||||
|
div class="w-2 h-2 rounded-full bg-emerald-500" {}
|
||||||
|
span class="font-medium text-sm text-slate-200" { "admin" }
|
||||||
|
}
|
||||||
|
span class="text-xs text-slate-500" { "10m ago" }
|
||||||
|
}
|
||||||
|
div class="space-y-1 mb-3" {
|
||||||
|
p class="text-xs text-slate-400 flex items-center gap-2" {
|
||||||
|
i class="ph ph-laptop" {} "Web Browser (Chrome)"
|
||||||
|
}
|
||||||
|
p class="text-xs text-slate-400 font-mono" { "192.168.1.5" }
|
||||||
|
}
|
||||||
|
div class="flex gap-2" {
|
||||||
|
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
|
||||||
|
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session item 2
|
||||||
|
div class="p-4 hover:bg-white/[0.02] transition-colors" {
|
||||||
|
div class="flex justify-between items-start mb-2" {
|
||||||
|
div class="flex items-center gap-2" {
|
||||||
|
div class="w-2 h-2 rounded-full bg-emerald-500" {}
|
||||||
|
span class="font-medium text-sm text-slate-200" { "guest" }
|
||||||
|
}
|
||||||
|
span class="text-xs text-slate-500" { "Just now" }
|
||||||
|
}
|
||||||
|
div class="space-y-1 mb-3" {
|
||||||
|
p class="text-xs text-slate-400 flex items-center gap-2" {
|
||||||
|
i class="ph ph-device-mobile" {} "DSub / 5.4.3"
|
||||||
|
}
|
||||||
|
p class="text-xs text-slate-400 font-mono" { "10.0.0.42" }
|
||||||
|
}
|
||||||
|
div class="flex gap-2" {
|
||||||
|
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
|
||||||
|
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_line(time: &str, level: &str, level_color: &str, msg: &str) -> Markup {
|
||||||
|
html! {
|
||||||
|
div class="flex gap-3 py-0.5 hover:bg-white/5 rounded px-1 transition-colors" {
|
||||||
|
span class="text-slate-500 shrink-0 w-20 text-xs" { (time) }
|
||||||
|
span class=(format!("shrink-0 w-12 text-xs font-bold {}", level_color)) { (level) }
|
||||||
|
span class="text-slate-300 break-all" { (msg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/admin/views/mod.rs
Normal file
10
src/admin/views/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod about;
|
||||||
|
pub mod login;
|
||||||
|
pub mod clients;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod library;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod playback;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod users;
|
||||||
125
src/admin/views/playback.rs
Normal file
125
src/admin/views/playback.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn playback() -> Markup {
|
||||||
|
layout("Playback & Streaming", html! {
|
||||||
|
div class="max-w-4xl mx-auto space-y-6" {
|
||||||
|
|
||||||
|
// Transcoding Settings
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-waveform text-purple-400" {} "Transcoding Rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-6 form-group" {
|
||||||
|
|
||||||
|
label class="flex items-center gap-4 cursor-pointer" {
|
||||||
|
div class="relative flex items-center" {
|
||||||
|
input type="checkbox" checked class="peer sr-only" {}
|
||||||
|
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-purple-500 peer-checked:border-purple-400 transition-all" {}
|
||||||
|
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||||
|
}
|
||||||
|
span class="text-sm font-medium text-slate-300" { "Enable Server-side Transcoding" }
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Default Max Bitrate" }
|
||||||
|
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||||
|
option value="0" { "No limit (Original)" }
|
||||||
|
option value="320" selected { "320 kbps" }
|
||||||
|
option value="256" { "256 kbps" }
|
||||||
|
option value="192" { "192 kbps" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Transcode Format Preference" }
|
||||||
|
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||||
|
option value="mp3" { "MP3 (Maximum Compatibility)" }
|
||||||
|
option value="opus" selected { "Opus (Best Quality/Size ratio)" }
|
||||||
|
option value="aac" { "AAC (Apple Devices)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming Settings
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-broadcast text-sky-400" {} "Streaming Buffers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-6" {
|
||||||
|
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-3 gap-6" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Chunk Size"
|
||||||
|
span class="text-xs text-slate-500" { "MB" }
|
||||||
|
}
|
||||||
|
input type="number" value="1" min="1" max="10" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Buffer Threshold"
|
||||||
|
span class="text-xs text-slate-500" { "Seconds" }
|
||||||
|
}
|
||||||
|
input type="number" value="10" min="5" max="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Preload Next Track"
|
||||||
|
span class="text-xs text-slate-500" { "Seconds" }
|
||||||
|
}
|
||||||
|
input type="number" value="15" min="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Compatibility
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-plugs-connected text-amber-400" {} "Subsonic API Compatibility"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-4" {
|
||||||
|
div class="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 p-4 rounded-lg" {
|
||||||
|
i class="ph ph-info text-amber-500 text-xl shrink-0" {}
|
||||||
|
p class="text-sm text-amber-200/80" { "Changes here affect how third-party apps like DSub, Play:Sub, or Ultrasonic parse server responses." }
|
||||||
|
}
|
||||||
|
|
||||||
|
label class="flex items-center gap-4 cursor-pointer mt-4" {
|
||||||
|
div class="relative flex items-center" {
|
||||||
|
input type="checkbox" class="peer sr-only" {}
|
||||||
|
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
|
||||||
|
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||||
|
}
|
||||||
|
span class="text-sm font-medium text-slate-300" { "Strict API Mode" }
|
||||||
|
}
|
||||||
|
p class="text-xs text-slate-500 ml-15 pl-1" { "When enabled, drops unsupported legacy fields from JSON responses. May break older clients." }
|
||||||
|
|
||||||
|
div class="mt-4" {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Reported API Version" }
|
||||||
|
select class="w-full md:w-1/2 bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm appearance-none" {
|
||||||
|
option value="1.16.1" selected { "OpenSubsonic 1.16.1 (Modern)" }
|
||||||
|
option value="1.15.0" { "Subsonic 1.15.0 (Legacy Default)" }
|
||||||
|
option value="1.13.0" { "Subsonic 1.13.0 (Old Clients)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="flex justify-end" {
|
||||||
|
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||||
|
i class="ph ph-floppy-disk text-lg" {} "Save Playback Options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
120
src/admin/views/settings.rs
Normal file
120
src/admin/views/settings.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn settings() -> Markup {
|
||||||
|
layout("System Settings", html! {
|
||||||
|
div class="max-w-4xl mx-auto space-y-6" {
|
||||||
|
|
||||||
|
// Server Network Base Settings
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-globe text-primary-400" {} "Network & Domain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-6" {
|
||||||
|
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
div class="md:col-span-2" {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Public Server URL" }
|
||||||
|
input type="url" value="https://music.home.arpa" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
|
||||||
|
p class="text-xs text-slate-500 mt-1.5" { "Used for generating share links and podcast enclosures." }
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Bind Port" }
|
||||||
|
input type="number" value="3311" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="flex items-end pb-2" {
|
||||||
|
label class="flex items-center gap-4 cursor-pointer" {
|
||||||
|
div class="relative flex items-center" {
|
||||||
|
input type="checkbox" checked class="peer sr-only" {}
|
||||||
|
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
|
||||||
|
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||||
|
}
|
||||||
|
span class="text-sm font-medium text-slate-300" { "Trust Proxy Headers (X-Forwarded-For)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Settings
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-shield-check text-rose-400" {} "Security Limits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-6" {
|
||||||
|
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
div class="flex flex-col justify-center" {
|
||||||
|
label class="flex items-center gap-4 cursor-pointer" {
|
||||||
|
div class="relative flex items-center" {
|
||||||
|
input type="checkbox" class="peer sr-only" {}
|
||||||
|
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-rose-500 peer-checked:border-rose-400 transition-all" {}
|
||||||
|
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
|
||||||
|
}
|
||||||
|
span class="text-sm font-medium text-slate-300" { "Allow Public Registration" }
|
||||||
|
}
|
||||||
|
p class="text-xs text-slate-500 ml-15 pl-1 mt-1" { "Users can self-provision accounts without an invite." }
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Session Timeout"
|
||||||
|
span class="text-xs text-slate-500" { "Days" }
|
||||||
|
}
|
||||||
|
input type="number" value="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Failed Login Threshold" }
|
||||||
|
input type="number" value="5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Lockout Duration"
|
||||||
|
span class="text-xs text-slate-500" { "Minutes" }
|
||||||
|
}
|
||||||
|
input type="number" value="15" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage Locations
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
|
||||||
|
h2 class="text-lg font-medium text-white flex items-center gap-2" {
|
||||||
|
i class="ph ph-database text-emerald-400" {} "Internal Storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="p-6 space-y-6" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Temp Directory" }
|
||||||
|
input type="text" value="/tmp/soundsonic" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all font-mono text-sm" {}
|
||||||
|
p class="text-xs text-slate-500 mt-1.5" { "Used for uploading chunks, transcoding frames, and zipping downloads." }
|
||||||
|
}
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Artwork Cache Size Limit"
|
||||||
|
span class="text-xs text-slate-500" { "GB" }
|
||||||
|
}
|
||||||
|
input type="number" value="1" step="0.5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="flex justify-end gap-4" {
|
||||||
|
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||||
|
i class="ph ph-floppy-disk text-lg" {} "Save Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
243
src/admin/views/users.rs
Normal file
243
src/admin/views/users.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use crate::admin::layout::layout;
|
||||||
|
|
||||||
|
pub async fn users() -> Markup {
|
||||||
|
layout("Users Management", html! {
|
||||||
|
div class="flex justify-between items-center mb-6" {
|
||||||
|
div {
|
||||||
|
h2 class="text-lg font-medium text-white" { "All Users" }
|
||||||
|
p class="text-sm text-slate-400 mt-1" { "Manage server access, roles, and capabilities." }
|
||||||
|
}
|
||||||
|
a href="/admin/users/new" class="bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||||
|
i class="ph ph-plus-circle text-lg" {}
|
||||||
|
"Create User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
|
||||||
|
div class="overflow-x-auto" {
|
||||||
|
table class="w-full text-left border-collapse" {
|
||||||
|
thead class="bg-black/30 text-xs uppercase tracking-wider text-slate-400" {
|
||||||
|
tr {
|
||||||
|
th class="px-6 py-4 font-semibold" { "User" }
|
||||||
|
th class="px-6 py-4 font-semibold" { "Roles & Access" }
|
||||||
|
th class="px-6 py-4 font-semibold" { "Last Seen" }
|
||||||
|
th class="px-6 py-4 font-semibold w-24" { "Status" }
|
||||||
|
th class="px-6 py-4 font-semibold text-right" { "Actions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody class="divide-y divide-white/5 text-sm" {
|
||||||
|
// Admin User Row
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-xs font-bold shadow-lg shadow-primary-500/20" { "A" }
|
||||||
|
div {
|
||||||
|
div class="font-medium text-white flex items-center gap-2" {
|
||||||
|
"admin"
|
||||||
|
i class="ph-fill ph-check-circle text-emerald-400 text-xs" title="Verified" {}
|
||||||
|
}
|
||||||
|
div class="text-xs text-slate-500 mt-0.5" { "admin@local" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
div class="flex flex-wrap gap-2" {
|
||||||
|
span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 text-[10px] font-bold tracking-wider" { "ADMIN" }
|
||||||
|
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
|
||||||
|
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Download" }
|
||||||
|
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Upload" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4 text-slate-400 text-xs" {
|
||||||
|
"Active now"
|
||||||
|
}
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" {
|
||||||
|
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {} "Active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
|
||||||
|
a href="/admin/users/1/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
|
||||||
|
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
|
||||||
|
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Disable" { i class="ph ph-prohibit" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest User Row
|
||||||
|
tr class="hover:bg-white/[0.02] transition-colors" {
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
div class="flex items-center gap-3" {
|
||||||
|
div class="w-8 h-8 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-xs font-bold text-slate-400" { "G" }
|
||||||
|
div {
|
||||||
|
div class="font-medium text-white" { "guest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
div class="flex flex-wrap gap-2" {
|
||||||
|
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4 text-slate-400 text-xs" {
|
||||||
|
"2 days ago"
|
||||||
|
}
|
||||||
|
td class="px-6 py-4" {
|
||||||
|
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-slate-800 text-slate-400 border border-slate-700" {
|
||||||
|
div class="w-1.5 h-1.5 rounded-full bg-slate-500" {} "Offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
|
||||||
|
a href="/admin/users/2/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
|
||||||
|
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
|
||||||
|
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Delete" { i class="ph ph-trash" {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_create() -> Markup {
|
||||||
|
user_form_layout("Create New User", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_edit() -> Markup {
|
||||||
|
user_form_layout("Edit User: admin", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_form_layout(title_text: &str, is_new: bool) -> Markup {
|
||||||
|
layout(title_text, html! {
|
||||||
|
div class="max-w-4xl mx-auto" {
|
||||||
|
// Header actions
|
||||||
|
div class="flex items-center gap-4 mb-6" {
|
||||||
|
a href="/admin/users" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors border border-white/5" {
|
||||||
|
i class="ph ph-arrow-left text-lg" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
h2 class="text-xl font-medium text-white" { (title_text) }
|
||||||
|
p class="text-sm text-slate-400 mt-1" { "Configure account credentials, limits, and server permissions." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form class="space-y-6" {
|
||||||
|
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
|
||||||
|
|
||||||
|
// Left Column: Credentials & Limits
|
||||||
|
div class="space-y-6 flex flex-col" {
|
||||||
|
div class="glass-card rounded-xl p-6 border border-white/5" {
|
||||||
|
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Credentials" }
|
||||||
|
div class="space-y-4" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
|
||||||
|
input type="text" value=(if!is_new{"admin"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all font-mono text-sm" placeholder="e.g. music_lover" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" {
|
||||||
|
"Password "
|
||||||
|
@if !is_new { span class="text-xs text-slate-500 font-normal ml-1" { "(Leave blank to keep unchanged)" } }
|
||||||
|
}
|
||||||
|
input type="password" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="••••••••" {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Email (Optional)" }
|
||||||
|
input type="email" value=(if!is_new{"admin@local"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="user@domain.com" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="glass-card rounded-xl p-6 border border-white/5 flex-1" {
|
||||||
|
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Limits & Quotas" }
|
||||||
|
div class="space-y-4" {
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Max Audio Bitrate"
|
||||||
|
span class="text-slate-500 font-normal text-xs" { "Kbps" }
|
||||||
|
}
|
||||||
|
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
|
||||||
|
option value="0" { "Unlimited (Direct Stream)" }
|
||||||
|
option value="320" { "320 kbps (High)" }
|
||||||
|
option value="192" { "192 kbps (Standard)" }
|
||||||
|
option value="128" { "128 kbps (Low)" }
|
||||||
|
option value="64" { "64 kbps (Cellular)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
|
||||||
|
"Storage Quota"
|
||||||
|
span class="text-slate-500 font-normal text-xs" { "GB" }
|
||||||
|
}
|
||||||
|
input type="number" min="0" value="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" {}
|
||||||
|
p class="text-xs text-slate-500 mt-2" { "Set to 0 for unlimited storage (if Upload is allowed)." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Column: Permissions
|
||||||
|
div class="glass-card rounded-xl p-6 border border-white/5 h-full" {
|
||||||
|
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5 flex items-center justify-between" {
|
||||||
|
"Permissions"
|
||||||
|
i class="ph ph-shield-check text-lg text-primary-400" {}
|
||||||
|
}
|
||||||
|
|
||||||
|
div class="space-y-4" {
|
||||||
|
// Admin Toggle
|
||||||
|
label class="flex items-start gap-4 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 cursor-pointer hover:bg-amber-500/20 transition-colors group" {
|
||||||
|
div class="relative flex items-center" {
|
||||||
|
input type="checkbox" checked[(!is_new)] class="peer sr-only" {}
|
||||||
|
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-amber-500 peer-checked:border-amber-400 transition-all" {}
|
||||||
|
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm shadow-black/50" {}
|
||||||
|
}
|
||||||
|
div class="flex-1" {
|
||||||
|
p class="text-sm font-medium text-amber-500 group-hover:text-amber-400 transition-colors" { "Administrator Privileges" }
|
||||||
|
p class="text-xs text-amber-500/70 mt-1" { "Grants full access to modify server settings, manage users, and view all system logs." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr class="border-white/5 my-4" {}
|
||||||
|
|
||||||
|
// Capability Toggles
|
||||||
|
(capability_toggle("Stream Media", "Stream music and video", true, "ph-play-circle"))
|
||||||
|
(capability_toggle("Download Files", "Download original media files", true, "ph-download-simple"))
|
||||||
|
(capability_toggle("Upload Media", "Upload files and modify library files", !is_new, "ph-upload-simple"))
|
||||||
|
(capability_toggle("Create Playlists", "Create, edit, and delete playlists", true, "ph-list-dashes"))
|
||||||
|
(capability_toggle("Share Links", "Create public sharing links for media", !is_new, "ph-share-network"))
|
||||||
|
(capability_toggle("Transcoding", "Allow server-side format conversion", true, "ph-waveform"))
|
||||||
|
(capability_toggle("Folder Access", "See raw folder structure not just tags", true, "ph-folder"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer Buttons
|
||||||
|
div class="flex items-center justify-end gap-4 mt-8 pt-6 border-t border-white/5" {
|
||||||
|
a href="/admin/users" class="px-5 py-2.5 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors" { "Cancel" }
|
||||||
|
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
|
||||||
|
i class="ph ph-floppy-disk text-lg" {}
|
||||||
|
"Save User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capability_toggle(title: &str, desc: &str, checked_by_default: bool, icon: &str) -> Markup {
|
||||||
|
html! {
|
||||||
|
label class="flex items-start gap-4 p-3 rounded-lg border border-transparent hover:bg-white/5 hover:border-white/10 cursor-pointer transition-all" {
|
||||||
|
div class="relative flex items-center shrink-0 mt-0.5" {
|
||||||
|
input type="checkbox" checked class="peer w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50 appearance-none checked:bg-primary-500 checked:border-primary-500 transition-all relative overflow-hidden flex items-center justify-center
|
||||||
|
after:content-[''] after:absolute after:w-2 after:h-2.5 after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:-translate-y-0.5 after:opacity-0 peer-checked:after:opacity-100" {}
|
||||||
|
}
|
||||||
|
div class="flex-1 flex gap-3" {
|
||||||
|
i class=(format!("ph {} text-xl text-slate-400 mt-0.5", icon)) {}
|
||||||
|
div {
|
||||||
|
p class="text-sm font-medium text-slate-200" { (title) }
|
||||||
|
p class="text-xs text-slate-500 mt-0.5" { (desc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/db/mod.rs
Normal file
1
src/db/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod models;
|
||||||
59
src/db/models.rs
Normal file
59
src/db/models.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::schema::{user_music_folders, users};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Selectable, Identifiable)]
|
||||||
|
#[diesel(table_name = users)]
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
pub ldapAuthenticated: bool,
|
||||||
|
pub adminRole: bool,
|
||||||
|
pub settingsRole: bool,
|
||||||
|
pub streamRole: bool,
|
||||||
|
pub jukeboxRole: bool,
|
||||||
|
pub downloadRole: bool,
|
||||||
|
pub uploadRole: bool,
|
||||||
|
pub playlistRole: bool,
|
||||||
|
pub coverArtRole: bool,
|
||||||
|
pub commentRole: bool,
|
||||||
|
pub podcastRole: bool,
|
||||||
|
pub shareRole: bool,
|
||||||
|
pub videoConversionRole: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Insertable)]
|
||||||
|
#[diesel(table_name = users)]
|
||||||
|
#[diesel(treat_none_as_default_value = true)]
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct NewUser {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
pub ldapAuthenticated: Option<bool>,
|
||||||
|
pub adminRole: Option<bool>,
|
||||||
|
pub settingsRole: Option<bool>,
|
||||||
|
pub streamRole: Option<bool>,
|
||||||
|
pub jukeboxRole: Option<bool>,
|
||||||
|
pub downloadRole: Option<bool>,
|
||||||
|
pub uploadRole: Option<bool>,
|
||||||
|
pub playlistRole: Option<bool>,
|
||||||
|
pub coverArtRole: Option<bool>,
|
||||||
|
pub commentRole: Option<bool>,
|
||||||
|
pub podcastRole: Option<bool>,
|
||||||
|
pub shareRole: Option<bool>,
|
||||||
|
pub videoConversionRole: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Selectable, Insertable)]
|
||||||
|
#[diesel(table_name = user_music_folders)]
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct UserMusicFolder {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub music_folder_id: i32,
|
||||||
|
}
|
||||||
39
src/main.rs
Normal file
39
src/main.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
mod admin;
|
||||||
|
mod db;
|
||||||
|
mod routes;
|
||||||
|
mod schema;
|
||||||
|
mod state;
|
||||||
|
mod types;
|
||||||
|
use axum::Router;
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use std::env;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use crate::routes::system_router::system_routers;
|
||||||
|
use crate::routes::user_management_router::user_management_routs;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::admin::routes::admin_router;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenv().ok();
|
||||||
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let state = AppState::new(&database_url)
|
||||||
|
.unwrap_or_else(|_| panic!("Error creating pool for {}", database_url));
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "tower_http=debug,axum=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
let app = Router::new()
|
||||||
|
.nest("/rest", system_routers().merge(user_management_routs()))
|
||||||
|
.nest("/admin", admin_router())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3311").await.unwrap();
|
||||||
|
let _ = axum::serve(listener, app).await;
|
||||||
|
}
|
||||||
2
src/routes/mod.rs
Normal file
2
src/routes/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod system_router;
|
||||||
|
pub mod user_management_router;
|
||||||
51
src/routes/system_router.rs
Normal file
51
src/routes/system_router.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use crate::schema::users;
|
||||||
|
use crate::types::types::OpenSubSonicResponses;
|
||||||
|
use crate::{state::AppState, types::types::OpenSubsonicAuth};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::{Json, Router, http::StatusCode, routing::get};
|
||||||
|
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||||
|
|
||||||
|
pub fn system_routers() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/getOpenSubsonicExtensions.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_extensions() -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_extensions()),
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/routes/user_management_router.rs
Normal file
57
src/routes/user_management_router.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use crate::{
|
||||||
|
db::models::NewUser,
|
||||||
|
schema::users,
|
||||||
|
state::AppState,
|
||||||
|
types::types::{OpenSubSonicResponses, OpenSubsonicErrorCode},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Form, State, rejection::FormRejection},
|
||||||
|
http::StatusCode,
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, insert_into};
|
||||||
|
|
||||||
|
pub fn user_management_routs() -> Router<AppState> {
|
||||||
|
Router::new().route("/createUser.view", get(create_user).post(create_user))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_if_user_exist(user_name: &String, db_con: &AppState) -> bool {
|
||||||
|
let conn = &mut db_con.pool.get().unwrap();
|
||||||
|
let result = users::table
|
||||||
|
.select(users::id)
|
||||||
|
.filter(users::username.eq(user_name))
|
||||||
|
.first::<Option<i32>>(conn);
|
||||||
|
match result {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
State(db_con): State<AppState>,
|
||||||
|
form: Result<Form<NewUser>, FormRejection>,
|
||||||
|
) -> (StatusCode, Json<OpenSubSonicResponses>) {
|
||||||
|
let params = if let Ok(Form(parsed)) = form {
|
||||||
|
parsed
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::MissingParameter,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if check_if_user_exist(¶ms.username, &db_con) {
|
||||||
|
let response = OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::WrongUsernameOrPassword,
|
||||||
|
);
|
||||||
|
return (StatusCode::CONFLICT, Json(response));
|
||||||
|
};
|
||||||
|
let conn = &mut db_con.pool.get().unwrap();
|
||||||
|
let _ = insert_into(users::table).values(params).execute(conn);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response()),
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/schema.rs
Normal file
34
src/schema.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
user_music_folders (user_id, music_folder_id) {
|
||||||
|
user_id -> Integer,
|
||||||
|
music_folder_id -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
users (id) {
|
||||||
|
id -> Nullable<Integer>,
|
||||||
|
username -> Text,
|
||||||
|
password -> Text,
|
||||||
|
email -> Text,
|
||||||
|
ldapAuthenticated -> Bool,
|
||||||
|
adminRole -> Bool,
|
||||||
|
settingsRole -> Bool,
|
||||||
|
streamRole -> Bool,
|
||||||
|
jukeboxRole -> Bool,
|
||||||
|
downloadRole -> Bool,
|
||||||
|
uploadRole -> Bool,
|
||||||
|
playlistRole -> Bool,
|
||||||
|
coverArtRole -> Bool,
|
||||||
|
commentRole -> Bool,
|
||||||
|
podcastRole -> Bool,
|
||||||
|
shareRole -> Bool,
|
||||||
|
videoConversionRole -> Bool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::joinable!(user_music_folders -> users (user_id));
|
||||||
|
|
||||||
|
diesel::allow_tables_to_appear_in_same_query!(user_music_folders, users,);
|
||||||
17
src/state.rs
Normal file
17
src/state.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use diesel::{
|
||||||
|
SqliteConnection,
|
||||||
|
r2d2::{ConnectionManager, Pool, PoolError},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: Pool<ConnectionManager<SqliteConnection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(database_url: &str) -> Result<Self, PoolError> {
|
||||||
|
let manager = ConnectionManager::<SqliteConnection>::new(database_url);
|
||||||
|
let pool = Pool::builder().build(manager)?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/types/mod.rs
Normal file
2
src/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod system;
|
||||||
|
pub mod types;
|
||||||
23
src/types/system.rs
Normal file
23
src/types/system.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct OpenSubSonicExtension {
|
||||||
|
pub name: String,
|
||||||
|
pub versions: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct OpenSubSonicExtensions {
|
||||||
|
pub openSubsonicExtensions: Vec<OpenSubSonicExtension>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct LicenseBody {
|
||||||
|
pub valid: bool,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct LicenseResponse {
|
||||||
|
pub license: LicenseBody,
|
||||||
|
}
|
||||||
311
src/types/types.rs
Normal file
311
src/types/types.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
use axum::{
|
||||||
|
Json, RequestPartsExt,
|
||||||
|
extract::{FromRequestParts, Query, RawForm},
|
||||||
|
http::{StatusCode, request::Parts},
|
||||||
|
middleware::{from_extractor, from_extractor_with_state},
|
||||||
|
};
|
||||||
|
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
schema::users,
|
||||||
|
state::AppState,
|
||||||
|
types::system::{LicenseBody, LicenseResponse, OpenSubSonicExtension, OpenSubSonicExtensions},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum OpenSubsonicErrorCode {
|
||||||
|
Generic = 0,
|
||||||
|
MissingParameter = 10,
|
||||||
|
ClientMustUpgrade = 20,
|
||||||
|
ServerMustUpgrade = 30,
|
||||||
|
WrongUsernameOrPassword = 40,
|
||||||
|
TokenAuthNotSupportedForLdap = 41,
|
||||||
|
AuthMechanismNotSupported = 42,
|
||||||
|
ConflictingAuthMechanisms = 43,
|
||||||
|
InvalidApiKey = 44,
|
||||||
|
NotAuthorized = 50,
|
||||||
|
TrialExpired = 60,
|
||||||
|
NotFound = 70,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenSubsonicErrorCode {
|
||||||
|
pub const fn description(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Generic => "A generic error.",
|
||||||
|
Self::MissingParameter => "Required parameter is missing.",
|
||||||
|
Self::ClientMustUpgrade => {
|
||||||
|
"Incompatible Subsonic REST protocol version. Client must upgrade."
|
||||||
|
}
|
||||||
|
Self::ServerMustUpgrade => {
|
||||||
|
"Incompatible Subsonic REST protocol version. Server must upgrade."
|
||||||
|
}
|
||||||
|
Self::WrongUsernameOrPassword => "Wrong username or password.",
|
||||||
|
Self::TokenAuthNotSupportedForLdap => {
|
||||||
|
"Token authentication not supported for LDAP users."
|
||||||
|
}
|
||||||
|
Self::AuthMechanismNotSupported => "Provided authentication mechanism not supported.",
|
||||||
|
Self::ConflictingAuthMechanisms => {
|
||||||
|
"Multiple conflicting authentication mechanisms provided."
|
||||||
|
}
|
||||||
|
Self::InvalidApiKey => "Invalid API key.",
|
||||||
|
Self::NotAuthorized => "User is not authorized for the given operation.",
|
||||||
|
Self::TrialExpired => {
|
||||||
|
"The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium."
|
||||||
|
}
|
||||||
|
Self::NotFound => "The requested data was not found.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OpenSubsonicBaseQuery {
|
||||||
|
pub u: String,
|
||||||
|
pub p: Option<String>,
|
||||||
|
pub t: Option<String>,
|
||||||
|
pub s: Option<String>,
|
||||||
|
pub apiKey: Option<String>,
|
||||||
|
pub v: String,
|
||||||
|
pub c: String,
|
||||||
|
pub f: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OpenSubsonicAuth {
|
||||||
|
pub username: String,
|
||||||
|
pub client: String,
|
||||||
|
pub version: String,
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for OpenSubsonicAuth {
|
||||||
|
type Rejection = (StatusCode, Json<OpenSubSonicResponses>);
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
// ---- 1. Parse query safely ----
|
||||||
|
let Query(query) = Query::<OpenSubsonicBaseQuery>::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::MissingParameter,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// ---- 2. Require token + salt ----
|
||||||
|
let (token, salt) = match (query.t.as_ref(), query.s.as_ref()) {
|
||||||
|
(Some(t), Some(s)) => (t, s),
|
||||||
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::AuthMechanismNotSupported,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 3. DB lookup (blocking -> spawn_blocking) ----
|
||||||
|
let username = query.u.clone();
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
|
||||||
|
let password = tokio::task::spawn_blocking(
|
||||||
|
move || -> Result<String, (StatusCode, Json<OpenSubSonicResponses>)> {
|
||||||
|
let mut conn = pool.get().map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::Generic,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
users::table
|
||||||
|
.select(users::password)
|
||||||
|
.filter(users::username.eq(username))
|
||||||
|
.first::<String>(&mut conn)
|
||||||
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::WrongUsernameOrPassword,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::Generic,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
})??;
|
||||||
|
|
||||||
|
// ---- 4. verify token ----
|
||||||
|
let expected_token = format!("{:x}", md5::compute(format!("{}{}", password, salt)));
|
||||||
|
|
||||||
|
if &expected_token != token {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(OpenSubSonicResponses::open_subsonic_response_error(
|
||||||
|
OpenSubsonicErrorCode::NotAuthorized,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OpenSubsonicAuth {
|
||||||
|
username: query.u,
|
||||||
|
client: query.c,
|
||||||
|
version: query.v,
|
||||||
|
format: query.f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BaseResponse<T> {
|
||||||
|
status: String,
|
||||||
|
version: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
type_name: String,
|
||||||
|
server_version: String,
|
||||||
|
open_subsonic: bool,
|
||||||
|
#[serde(flatten)]
|
||||||
|
data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ErrorResponseBody {
|
||||||
|
code: u8,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
error: ErrorResponseBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum OpenSubSonicResponses {
|
||||||
|
OpenSubSonicResponseError {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: BaseResponse<ErrorResponse>,
|
||||||
|
},
|
||||||
|
OpenSubSonicResponse {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: BaseResponse<()>,
|
||||||
|
},
|
||||||
|
OpenSubSonicExtensions {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: BaseResponse<OpenSubSonicExtensions>,
|
||||||
|
// openSubSonicExtensions: Vec<OpenSubSonicExtension>,
|
||||||
|
},
|
||||||
|
License {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: BaseResponse<LicenseResponse>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenSubSonicResponses {
|
||||||
|
fn base_response<T>(inner_response: Option<T>) -> BaseResponse<T> {
|
||||||
|
BaseResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
version: "1.16.1".to_string(),
|
||||||
|
type_name: "SoundSonic".to_string(),
|
||||||
|
server_version: "1.16.1".to_string(),
|
||||||
|
open_subsonic: true,
|
||||||
|
data: inner_response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn license_response(email: Option<String>) -> Self {
|
||||||
|
match email {
|
||||||
|
Some(_) => Self::License {
|
||||||
|
subsonic_response: Self::base_response(Some(LicenseResponse {
|
||||||
|
license: LicenseBody {
|
||||||
|
valid: true,
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
None => Self::License {
|
||||||
|
subsonic_response: Self::base_response(Some(LicenseResponse {
|
||||||
|
license: LicenseBody {
|
||||||
|
valid: true,
|
||||||
|
email: None,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_subsonic_response_error(error_code: OpenSubsonicErrorCode) -> Self {
|
||||||
|
Self::OpenSubSonicResponseError {
|
||||||
|
subsonic_response: BaseResponse {
|
||||||
|
status: "failed".to_string(),
|
||||||
|
version: "1.16.1".to_string(),
|
||||||
|
type_name: "SoundSonic".to_string(),
|
||||||
|
server_version: "1.16.1".to_string(),
|
||||||
|
open_subsonic: true,
|
||||||
|
data: Some(ErrorResponse {
|
||||||
|
error: ErrorResponseBody {
|
||||||
|
code: error_code as u8,
|
||||||
|
message: error_code.description().to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_subsonic_response() -> Self {
|
||||||
|
Self::OpenSubSonicResponse {
|
||||||
|
subsonic_response: Self::base_response(None::<()>),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_subsonic_extensions() -> Self {
|
||||||
|
let mut extension: Vec<OpenSubSonicExtension> = Vec::new();
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "apiKeyAuthentication".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "formPost".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "indexBasedQueue".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "songLyrics".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "transcodeOffset".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
extension.push(OpenSubSonicExtension {
|
||||||
|
name: "transcoding".to_string(),
|
||||||
|
versions: vec![1],
|
||||||
|
});
|
||||||
|
|
||||||
|
Self::OpenSubSonicExtensions {
|
||||||
|
subsonic_response: Self::base_response(Some(OpenSubSonicExtensions {
|
||||||
|
openSubsonicExtensions: extension,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user