Compare commits

..

6 Commits

Author SHA1 Message Date
vaibhav
3f33bcdf32 Feat: Implement Admin Dashboard UI
This commit introduces the foundational UI for the admin dashboard. It
includes:
- A new `maud`-based layout component for consistent page structure.
- A navigation sidebar and header.
- Basic views for dashboard overview, user management, library settings,
  and about page.
- Integration with Tailwind CSS and Phosphor Icons for styling.
- Added `maud` and `axum-core` as dependencies.
2026-03-15 05:38:48 +05:30
vaibhav
c862669b08 feat: Add tracing and md5 dependency
Adds the `tracing` and `md5` crates to the project, enabling enhanced
logging and cryptographic hashing capabilities. Includes `TraceLayer`
for HTTP request tracing and configures `tracing-subscriber` with
`EnvFilter`.
2026-02-22 02:44:58 +05:30
vaibhav
cae0136aaf Update README with current status and features
Clarify that the project implements *parts* of the OpenSubsonic API. Add
a section detailing the current status and explicitly list implemented
features. Update installation steps to include installing the Diesel
CLI. Add an example for the `createUser.view` endpoint.
2026-02-14 03:48:48 +05:30
vaibhav
123a409e14 Add README.md with project overview
This commit introduces the README.md file for the SoundSonic project.
It provides a comprehensive overview of the project, its features,
technologies used, installation instructions, API endpoints, user roles,
configuration details, development guidance, supported extensions,
license,
contributing guidelines, and acknowledgments.
2026-02-14 03:38:20 +05:30
vaibhav
fcaf70b717 Add user management routes
Integrate the new user management routes into the main application
router. This commit also includes refactoring of the OpenSubsonic
response types to better accommodate error responses and introduces a
new struct for handling multiple extensions.
2026-02-14 03:36:11 +05:30
vaibhav
9bf9a2296e feat: Add initial project structure and dependencies
This commit introduces the foundational elements of the project:
- `.gitignore` file to exclude build artifacts and environment
  variables.
- `Cargo.lock` and `Cargo.toml` defining project dependencies and
  metadata.
- `diesel.toml` for Diesel CLI configuration.
- Initial migration files (`down.sql`, `up.sql`) for database schema
  setup.
- `rustfmt.toml` for code formatting.
- Basic module structure for database, routes, schema, state, and types.
- `src/main.rs` with basic Axum server setup and dotenv loading.
- `src/routes/system_router.rs` for basic API endpoint.
- `src/state.rs` for managing application state and database connection
  pool.
- Type definitions for Subsonic API responses and extensions.
2026-02-14 03:36:11 +05:30
35 changed files with 3565 additions and 2 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

1203
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View 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
View File

@@ -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
View 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
View File

0
migrations/.keep Normal file
View File

View 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;

View 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
View File

@@ -0,0 +1 @@
edition = "2024"

150
src/admin/layout.rs Normal file
View File

@@ -0,0 +1,150 @@
use maud::{html, Markup, DOCTYPE};
pub fn layout(title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "SoundSonic Admin - " (title) }
script src="https://cdn.tailwindcss.com" {}
// Tailwind Config for custom colors/fonts if needed
script {
(maud::PreEscaped(r##"
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
}
}
}
}
"##))
}
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
// Add Phosphor icons for beautiful lightweight UI icons
script src="https://unpkg.com/@phosphor-icons/web" {}
style {
(maud::PreEscaped(r##"
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
/* Glassmorphism utilities */
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-card {
background: rgba(30, 41, 59, 0.4);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.1);
}
.nav-link { transition: all 0.2s ease-in-out; border-left: 3px solid transparent; }
.nav-link:hover { background-color: rgba(51, 65, 85, 0.5); color: #fff; border-left-color: #3b82f6; }
.nav-link.active { background-color: rgba(51, 65, 85, 0.8); color: #fff; border-left-color: #3b82f6; font-weight: 500; }
/* Custom scrollbar for dark mode */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
"##))
}
}
body class="h-screen flex overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
// Sidebar
aside class="w-64 glass flex flex-col z-20 flex-shrink-0" {
div class="h-16 flex items-center px-6 font-bold text-xl tracking-tight border-b border-white/10 gap-2 text-white" {
i class="ph-fill ph-waves text-primary-500 text-2xl" {}
"SoundSonic"
}
nav class="flex-1 overflow-y-auto py-4 space-y-1" {
// Using simplistic path matching logic conceptually handled in UI. We'll just define the links.
a href="/admin" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-squares-four text-lg" {} "Dashboard"
}
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Manage" }
a href="/admin/users" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-users text-lg" {} "Users & Roles"
}
a href="/admin/library" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-books text-lg" {} "Library"
}
a href="/admin/clients" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-devices text-lg" {} "API Clients"
}
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Server" }
a href="/admin/playback" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-play-circle text-lg" {} "Playback/Stream"
}
a href="/admin/settings" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-gear text-lg" {} "Settings"
}
a href="/admin/jobs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-arrows-clockwise text-lg" {} "Background Jobs"
}
div class="px-6 pt-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wider" { "Diagnostics" }
a href="/admin/logs" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-terminal-window text-lg" {} "Logs & Debug"
}
a href="/admin/about" class="nav-link flex items-center gap-3 px-6 py-2.5 text-sm font-medium text-slate-300" {
i class="ph ph-info text-lg" {} "About"
}
}
div class="p-4 border-t border-white/10" {
div class="flex items-center gap-3 px-2" {
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-sm font-bold shadow-lg shadow-primary-500/20" {
"A"
}
div class="flex-1 min-w-0" {
p class="text-sm font-medium text-white truncate" { "Administrator" }
p class="text-xs text-slate-400 truncate" { "admin@local" }
}
}
}
}
// Main Content
main class="flex-1 flex flex-col min-w-0 overflow-hidden relative" {
// Decorative background glow
div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-primary-900/20 blur-[120px] pointer-events-none" {}
div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-900/10 blur-[120px] pointer-events-none" {}
header class="h-16 glass flex items-center justify-between px-8 z-10" {
h1 class="text-xl font-semibold text-white tracking-tight flex items-center gap-2" {
(title)
}
div class="flex items-center gap-4 text-slate-400" {
button class="hover:text-white transition-colors" title="Notifications" { i class="ph ph-bell text-xl" {} }
button class="hover:text-white transition-colors" title="Server Status" {
div class="relative" {
i class="ph ph-hard-drives text-xl" {}
span class="absolute top-0 right-0 w-2 h-2 bg-emerald-500 rounded-full border border-slate-900" {}
}
}
a href="/rest/ping.view" class="text-sm border border-slate-700 hover:bg-slate-800 px-3 py-1.5 rounded-md transition-colors flex items-center gap-2" {
i class="ph ph-sign-out" {} "Exit Panel"
}
}
}
div class="flex-1 overflow-y-auto p-8 z-10" {
div class="max-w-7xl mx-auto space-y-6" {
(content)
}
}
}
}
}
}
}

3
src/admin/mod.rs Normal file
View File

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

24
src/admin/routes.rs Normal file
View File

@@ -0,0 +1,24 @@
use axum::{routing::get, Router};
use crate::state::AppState;
use crate::admin::views::{
dashboard, users, library, playback, settings, logs, clients, jobs, about, login
};
pub fn admin_router() -> Router<AppState> {
Router::new()
.route("/login", get(login::login_form).post(login::login_post))
.route("/", get(dashboard::index))
.route("/users", get(users::users))
.route("/users/new", get(users::user_create))
.route("/users/{id}/edit", get(users::user_edit))
.route("/library", get(library::library))
.route("/playback", get(playback::playback))
.route("/settings", get(settings::settings))
.route("/logs", get(logs::logs))
.route("/clients", get(clients::clients))
.route("/jobs", get(jobs::jobs))
.route("/about", get(about::about))
}

99
src/admin/views/about.rs Normal file
View File

@@ -0,0 +1,99 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn about() -> Markup {
layout("About & Diagnostics", html! {
div class="max-w-3xl mx-auto space-y-6" {
// Server Identity Card
div class="glass-card rounded-xl border border-white/5 overflow-hidden text-center p-8 space-y-4 relative" {
div class="w-24 h-24 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 mx-auto flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20" {
i class="ph-fill ph-waves text-5xl text-white" {}
}
div {
h2 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic" }
p class="text-primary-400 font-mono text-sm mt-1" { "v0.1.0-alpha.5 (build 4f8a9b2)" }
}
p class="text-slate-400 text-sm max-w-lg mx-auto" {
"A blazing fast, lightweight Subsonic-compatible media server written in Rust."
}
div class="flex justify-center gap-4 mt-6 pt-6 border-t border-white/5" {
a href="#" class="text-slate-400 hover:text-white transition-colors flex flex-col items-center gap-1" {
i class="ph ph-github-logo text-3xl" {}
span class="text-xs font-medium" { "GitHub" }
}
a href="#" class="text-slate-400 hover:text-primary-400 transition-colors flex flex-col items-center gap-1" {
i class="ph ph-bug text-3xl" {}
span class="text-xs font-medium" { "Report Issue" }
}
a href="#" class="text-slate-400 hover:text-indigo-400 transition-colors flex flex-col items-center gap-1" {
i class="ph ph-book-open text-3xl" {}
span class="text-xs font-medium" { "Docs" }
}
}
}
// Diagnostics
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
i class="ph ph-info" {} "System Details"
}
}
div class="p-0" {
table class="w-full text-sm" {
tbody class="divide-y divide-white/5" {
tr class="hover:bg-white/[0.02]" {
td class="py-3 px-6 text-slate-500" { "OS" }
td class="py-3 px-6 text-slate-300 font-mono text-right" { "Linux 6.8.0-generic" }
}
tr class="hover:bg-white/[0.02]" {
td class="py-3 px-6 text-slate-500" { "Architecture" }
td class="py-3 px-6 text-slate-300 font-mono text-right" { "x86_64" }
}
tr class="hover:bg-white/[0.02]" {
td class="py-3 px-6 text-slate-500" { "Rust Version" }
td class="py-3 px-6 text-slate-300 font-mono text-right" { "rustc 1.77.0" }
}
tr class="hover:bg-white/[0.02]" {
td class="py-3 px-6 text-slate-500" { "Database" }
td class="py-3 px-6 text-slate-300 font-mono text-right" { "SQLite 3.45.1" }
}
}
}
}
}
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h3 class="text-sm font-medium text-white flex items-center gap-2 uppercase tracking-wider" {
i class="ph ph-plugs-connected" {} "API Supported Extensions"
}
}
div class="p-6 text-sm" {
ul class="space-y-3" {
li class="flex items-center gap-3 text-slate-300" {
i class="ph-fill ph-check-circle text-emerald-400" {} "subsonic"
}
li class="flex items-center gap-3 text-slate-300" {
i class="ph-fill ph-check-circle text-emerald-400" {} "transcodeOffset"
}
li class="flex items-center gap-3 text-slate-300" {
i class="ph-fill ph-check-circle text-emerald-400" {} "podcast"
}
li class="flex items-center gap-3 text-slate-300" {
i class="ph-fill ph-check-circle text-emerald-400" {} "videoConversion"
}
li class="flex items-center gap-3 text-slate-300" {
i class="ph-fill ph-check-circle text-emerald-400" {} "playQueue"
}
}
}
}
}
}
})
}

View File

@@ -0,0 +1,77 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn clients() -> Markup {
layout("API Clients & Tokens", html! {
div class="flex flex-col gap-6" {
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-devices text-primary-400" {} "Connected Clients"
}
div class="flex gap-2" {
span class="px-3 py-1 rounded bg-slate-800 text-slate-400 border border-white/5 text-xs font-medium" { "Token Auth Enabled" }
}
}
div class="p-6 overflow-x-auto" {
table class="w-full text-left" {
thead class="bg-transparent text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
tr {
th class="pb-3 font-semibold" { "Client Name" }
th class="pb-3 font-semibold" { "Version" }
th class="pb-3 font-semibold" { "Owner / User" }
th class="pb-3 font-semibold" { "Last Used" }
th class="pb-3 font-semibold text-right" { "Actions" }
}
}
tbody class="text-sm divide-y divide-white/5" {
tr class="hover:bg-white/[0.02] transition-colors" {
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
i class="ph ph-device-mobile text-xl text-indigo-400" {}
"Ultrasonic"
}
td class="py-4 text-slate-400" { "3.0.0-git" }
td class="py-4 text-slate-300" { "admin" }
td class="py-4 text-slate-400" { "2 hours ago" }
td class="py-4 text-right" {
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
i class="ph ph-key" {} "Revoke Token"
}
}
}
tr class="hover:bg-white/[0.02] transition-colors" {
td class="py-4 text-slate-300 font-medium flex items-center gap-3" {
i class="ph ph-browser text-xl text-sky-400" {}
"WebPlayer"
}
td class="py-4 text-slate-400" { "1.0.0" }
td class="py-4 text-slate-300" { "admin" }
td class="py-4 text-slate-400" { "Right now" }
td class="py-4 text-right" {
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
i class="ph ph-key" {} "Revoke Token"
}
}
}
tr class="hover:bg-white/[0.02] transition-colors text-slate-500" {
td class="py-4 font-medium flex items-center gap-3" {
i class="ph ph-car text-xl" {}
"Android Auto Sub"
}
td class="py-4" { "1.2" }
td class="py-4" { "guest" }
td class="py-4" { "6 months ago" }
td class="py-4 text-right" {
button class="text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 p-2 rounded-md border border-rose-500/20 text-xs font-medium flex items-center gap-2 ml-auto" title="Revoke access" {
i class="ph ph-key" {} "Revoke Token"
}
}
}
}
}
}
}
}
})
}

View File

@@ -0,0 +1,171 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn index() -> Markup {
layout("Dashboard Overview", html! {
// Top Stats Row
div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" {
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
i class="ph-fill ph-music-notes text-6xl text-primary-500" {}
}
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Songs" }
p class="text-4xl font-bold text-white mb-1" { "42,891" }
p class="text-xs text-emerald-400 flex items-center gap-1" {
i class="ph-bold ph-trend-up" {} "+124 this week"
}
}
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
i class="ph-fill ph-vinyl-record text-6xl text-purple-500" {}
}
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Albums" }
p class="text-4xl font-bold text-white mb-1" { "3,402" }
p class="text-xs text-emerald-400 flex items-center gap-1" {
i class="ph-bold ph-trend-up" {} "+12 this week"
}
}
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
i class="ph-fill ph-users text-6xl text-amber-500" {}
}
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Total Users" }
p class="text-4xl font-bold text-white mb-1" { "128" }
p class="text-xs text-emerald-400 flex items-center gap-1" {
i class="ph-bold ph-trend-up" {} "+3 this week"
}
}
div class="glass-card rounded-xl p-6 relative overflow-hidden group hover:-translate-y-1 transition-transform" {
div class="absolute top-0 right-0 p-4 opacity-20 group-hover:opacity-40 transition-opacity" {
i class="ph-fill ph-headphones text-6xl text-pink-500" {}
}
h3 class="text-slate-400 text-sm font-medium uppercase tracking-wider mb-2" { "Active Sessions" }
p class="text-4xl font-bold text-white flex items-center gap-3 mb-1" {
"14"
span class="relative flex h-3 w-3" {
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75" {}
span class="relative inline-flex rounded-full h-3 w-3 bg-pink-500" {}
}
}
p class="text-xs text-slate-400 flex items-center gap-1" {
"Right now"
}
}
}
// Server Health & Quick Actions
div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6" {
// Server Health
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-heartbeat text-primary-400" {} "Server Health"
}
span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5" {
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {}
"Online"
}
}
div class="p-6 grid grid-cols-2 md:grid-cols-4 gap-6" {
div {
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Uptime" }
p class="text-xl font-medium text-white" { "14d 6h 22m" }
}
div {
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "CPU Usage" }
p class="text-xl font-medium text-white flex items-center gap-2" {
"12%"
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
div class="h-full bg-primary-500 w-[12%]" {}
}
}
}
div {
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "RAM Usage" }
p class="text-xl font-medium text-white flex items-center gap-2" {
"2.4 GB"
div class="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" {
div class="h-full bg-amber-500 w-[45%]" {}
}
}
}
div {
p class="text-slate-400 text-xs mb-1 uppercase tracking-wider font-semibold" { "Last Scan" }
p class="text-xl font-medium text-white" { "2 mins ago" }
}
}
// Recent Errors
div class="px-6 py-3 bg-black/30 border-t border-white/5 border-b border-white/5" {
p class="text-xs font-medium text-slate-400 uppercase tracking-wider" { "Recent Log Activity" }
}
div class="flex-1 p-6 space-y-3 overflow-y-auto max-h-[300px] font-mono text-sm" {
div class="flex gap-3 text-slate-300" {
span class="text-slate-500 shrink-0" { "10:42:01 AM" }
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
span class="truncate" { "Library scan completed in 4.2s." }
}
div class="flex gap-3 text-slate-300" {
span class="text-slate-500 shrink-0" { "10:39:15 AM" }
span class="text-amber-400 shrink-0 w-12" { "WARN " }
span class="truncate" { "Failed to resolve cover art for /music/Unknown/Track01.mp3" }
}
div class="flex gap-3 text-slate-300" {
span class="text-slate-500 shrink-0" { "09:12:33 AM" }
span class="text-rose-400 shrink-0 w-12" { "ERROR" }
span class="truncate" { "Database connection timeout occurred during sync." }
}
div class="flex gap-3 text-slate-300" {
span class="text-slate-500 shrink-0" { "08:00:00 AM" }
span class="text-emerald-400 shrink-0 w-12" { "INFO " }
span class="truncate" { "Daily backup completed successfully." }
}
}
}
// Actions
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-lightning text-amber-400" {} "Quick Actions"
}
}
div class="p-6 space-y-4" {
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600 hover:bg-primary-500 text-white transition-colors group shadow-lg shadow-primary-900/20" {
div class="flex items-center gap-3" {
i class="ph ph-arrows-clockwise text-xl group-hover:rotate-180 transition-transform duration-500" {}
span class="font-medium" { "Start Library Scan" }
}
i class="ph ph-caret-right text-slate-300" {}
}
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
div class="flex items-center gap-3" {
i class="ph ph-stop-circle text-xl text-rose-400" {}
span class="font-medium inline-block" { "Stop Current Scan" }
}
span class="text-xs bg-black/30 px-2 py-1 rounded text-slate-400" { "Idle" }
}
button class="w-full flex items-center justify-between p-4 rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition-colors border border-white/5" {
div class="flex items-center gap-3" {
i class="ph ph-broom text-xl text-amber-400" {}
span class="font-medium inline-block" { "Clear Image Cache" }
}
span class="text-xs text-slate-400" { "442 MB" }
}
hr class="border-white/5 my-2" {}
button class="w-full flex items-center justify-between p-4 rounded-lg bg-rose-900/40 hover:bg-rose-900/80 text-rose-200 transition-colors border border-rose-500/20" {
div class="flex items-center gap-3" {
i class="ph ph-power text-xl" {}
span class="font-medium inline-block" { "Restart Server" }
}
}
}
}
}
})
}

109
src/admin/views/jobs.rs Normal file
View File

@@ -0,0 +1,109 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn jobs() -> Markup {
layout("Background Jobs", html! {
div class="flex justify-between items-center mb-6" {
div {
h2 class="text-lg font-medium text-white" { "Job Queue" }
p class="text-sm text-slate-400 mt-1" { "Monitor long-running tasks and scanners." }
}
button class="text-slate-300 hover:text-white transition-colors bg-black/30 p-2 rounded-lg border border-white/5 flex items-center gap-2" title="Refresh" {
i class="ph ph-arrows-clockwise text-lg" {} "Refresh"
}
}
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="p-6 space-y-4" {
// Active Job
div class="p-4 rounded-xl border border-primary-500/30 bg-primary-900/10 flex flex-col md:flex-row gap-4 items-center" {
div class="w-12 h-12 rounded-full bg-primary-500/20 text-primary-400 flex items-center justify-center shrink-0 border border-primary-500/30" {
i class="ph ph-magnifying-glass text-2xl animate-pulse" {}
}
div class="flex-1 w-full" {
div class="flex justify-between items-center mb-2" {
h3 class="text-sm font-medium text-primary-200" { "Library Metadata Rescan" }
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-primary-500/20 text-primary-400 border border-primary-500/30" { "Running" }
}
div class="flex justify-between text-xs text-primary-400/80 mb-1" {
span { "Processing /music/Lossless/Pink Floyd..." }
span { "45%" }
}
div class="w-full h-1.5 bg-black/40 rounded-full overflow-hidden border border-white/5" {
div class="h-full bg-primary-500 w-[45%] relative overflow-hidden" {
div class="absolute inset-0 w-full h-full bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent)] animate-[shimmer_2s_infinite]" {}
}
}
}
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Cancel" }
}
}
// Queued Job
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center opacity-75 grayscale-[30%]" {
div class="w-12 h-12 rounded-full bg-slate-800 text-slate-400 flex items-center justify-center shrink-0 border border-white/10" {
i class="ph ph-image-square text-2xl" {}
}
div class="flex-1 w-full" {
div class="flex justify-between items-center" {
div {
h3 class="text-sm font-medium text-slate-200" { "Download Missing Artwork" }
p class="text-xs text-slate-400 mt-0.5" { "Queued behind Library Scan" }
}
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-slate-800 text-slate-400 border border-white/10" { "Pending" }
}
}
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold transition-colors border border-rose-500/20" { "Remove" }
}
}
// Completed Job
div class="p-4 rounded-xl border border-white/5 bg-black/20 flex flex-col md:flex-row gap-4 items-center" {
div class="w-12 h-12 rounded-full bg-emerald-500/10 text-emerald-400 flex items-center justify-center shrink-0 border border-emerald-500/20" {
i class="ph-fill ph-check-circle text-2xl" {}
}
div class="flex-1 w-full" {
div class="flex justify-between items-center" {
div {
h3 class="text-sm font-medium text-slate-200" { "Daily Database Backup" }
p class="text-xs text-slate-400 mt-0.5" { "Completed in 1.4s at 03:00 AM" }
}
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" { "Success" }
}
}
}
// Failed Job
div class="p-4 rounded-xl border border-rose-500/20 bg-rose-500/5 flex flex-col md:flex-row gap-4 items-center" {
div class="w-12 h-12 rounded-full bg-rose-500/10 text-rose-400 flex items-center justify-center shrink-0 border border-rose-500/30" {
i class="ph-fill ph-warning-circle text-2xl" {}
}
div class="flex-1 w-full" {
div class="flex justify-between items-center" {
div {
h3 class="text-sm font-medium text-rose-200" { "Generate Waveforms" }
p class="text-xs text-rose-400/80 mt-0.5" { "Failed: Error running FFmpeg command on Track14.flac" }
}
span class="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider bg-rose-500/20 text-rose-400 border border-rose-500/30" { "Failed" }
}
}
div class="shrink-0 flex gap-2 w-full md:w-auto mt-4 md:mt-0" {
button class="w-full md:w-auto px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors border border-white/5" { "Retry" }
}
}
}
}
style {
r#"
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
"#
}
})
}

156
src/admin/views/library.rs Normal file
View File

@@ -0,0 +1,156 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn library() -> Markup {
layout("Library Management", html! {
div class="flex flex-col gap-6" {
// Library Paths
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-hard-drives text-primary-400" {} "Media Folders"
}
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-2" {
i class="ph ph-plus" {} "Add Directory"
}
}
div class="p-6 overflow-x-auto" {
table class="w-full text-left" {
thead class="text-xs uppercase tracking-wider text-slate-500 border-b border-white/5" {
tr {
th class="pb-3 font-semibold" { "Path" }
th class="pb-3 font-semibold w-32" { "Scan Mode" }
th class="pb-3 font-semibold text-right" { "Actions" }
}
}
tbody class="text-sm divide-y divide-white/5" {
tr class="hover:bg-white/[0.02] transition-colors" {
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
i class="ph-fill ph-folder text-amber-500 text-lg" {}
"/data/music/FLACs"
}
td class="py-4" {
span class="px-2 py-1 rounded text-xs tracking-wide bg-blue-500/10 text-blue-400 border border-blue-500/20" { "Inotify Watch" }
}
td class="py-4 text-right space-x-2" {
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
}
}
tr class="hover:bg-white/[0.02] transition-colors" {
td class="py-4 font-mono text-slate-300 flex items-center gap-3" {
i class="ph-fill ph-folder text-amber-500 text-lg" {}
"/mnt/external_hdd/MP3s"
}
td class="py-4" {
span class="px-2 py-1 rounded text-xs tracking-wide bg-slate-800 text-slate-400 border border-white/10" { "Scheduled" }
}
td class="py-4 text-right space-x-2" {
button class="text-slate-400 hover:text-primary-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Rescan immediately" { i class="ph ph-arrows-clockwise text-lg" {} }
button class="text-slate-400 hover:text-rose-400 transition-colors bg-black/30 p-2 rounded-md border border-white/5" title="Remove" { i class="ph ph-trash text-lg" {} }
}
}
}
}
}
}
div class="grid grid-cols-1 lg:grid-cols-2 gap-6" {
// Scan Controls
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-scan text-purple-400" {} "Scan Engine"
}
}
div class="p-6 flex-1 space-y-6" {
div class="space-y-3" {
button class="w-full flex items-center justify-between p-4 rounded-lg bg-primary-600/20 hover:bg-primary-600/40 text-primary-300 transition-colors border border-primary-500/30 group" {
div class="flex items-center gap-3" {
i class="ph ph-magnifying-glass text-xl group-hover:scale-110 transition-transform" {}
div class="text-left" {
p class="font-medium text-primary-100" { "Full Metadata Scan" }
p class="text-xs text-primary-400/70" { "Scans all files and applies new tag modifications." }
}
}
i class="ph ph-caret-right text-lg opacity-50" {}
}
button class="w-full flex items-center justify-between p-4 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-300 transition-colors border border-emerald-500/30 group" {
div class="flex items-center gap-3" {
i class="ph ph-music-notes-plus text-xl group-hover:scale-110 transition-transform" {}
div class="text-left" {
p class="font-medium text-emerald-100" { "Quick Scan" }
p class="text-xs text-emerald-400/70" { "Only scans for newly added or deleted files based on timestamps." }
}
}
i class="ph ph-caret-right text-lg opacity-50" {}
}
}
hr class="border-white/5" {}
div class="space-y-4" {
p class="text-sm font-medium text-slate-300 mb-2" { "Scan Scheduling" }
label class="flex items-center gap-3 text-sm text-slate-400" {
input type="checkbox" checked class="w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50" {}
"Enable file system watchers (inotify) for instant updates"
}
div class="flex items-center gap-3" {
label class="text-sm text-slate-400" { "Full scan interval:" }
select class="bg-black/30 border border-white/10 rounded border-white/10 px-3 py-1 text-sm text-white outline-none focus:border-primary-500" {
option value="0" { "Never" }
option value="24" selected { "Daily (at 3 AM)" }
option value="168" { "Weekly" }
}
}
}
}
}
// Metadata Tools
div class="glass-card rounded-xl border border-white/5 overflow-hidden flex flex-col" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-wrench text-amber-400" {} "Maintenance Tools"
}
}
div class="p-6 flex-1 space-y-4" {
div class="p-4 rounded-lg border border-rose-500/20 bg-rose-500/5 flex gap-4" {
div class="mt-0.5" { i class="ph ph-warning-circle text-xl text-rose-400" {} }
div {
h3 class="text-sm font-medium text-rose-200" { "Clean Database Orphan Links" }
p class="text-xs text-rose-400/70 mt-1 mb-3" { "Removes database entries for files that no longer exist on the filesystem. Use with caution." }
button class="px-4 py-1.5 rounded bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-semibold uppercase tracking-wider transition-colors" { "Clean Orphans" }
}
}
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
div class="mt-0.5" { i class="ph ph-image-square text-xl text-slate-400" {} }
div {
h3 class="text-sm font-medium text-slate-200" { "Rebuild Album Art Cache" }
p class="text-xs text-slate-500 mt-1 mb-3" { "Forces regeneration of thumbnail images and clears missing artwork cache." }
button class="px-4 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold uppercase tracking-wider transition-colors border border-white/5" { "Rebuild Art" }
}
}
div class="p-4 rounded-lg border border-white/5 bg-black/20 flex gap-4" {
div class="mt-0.5" { i class="ph ph-waveform text-xl text-slate-400" {} }
div {
h3 class="text-sm font-medium text-slate-200" { "Generate Waveforms" }
p class="text-xs text-slate-500 mt-1 mb-3 flex items-center gap-2" {
"Pre-computes audio waveform files for faster sought-scrubbing. This is very CPU intensive."
span class="px-1.5 py-0.5 bg-indigo-500/20 border border-indigo-500/30 text-[10px] uppercase font-bold text-indigo-400 rounded" {"Coming Soon"}
}
button disabled class="px-4 py-1.5 rounded bg-slate-800/50 text-slate-600 text-xs font-semibold uppercase tracking-wider pointer-events-none border border-white/5" { "Generate" }
}
}
}
}
}
}
})
}

114
src/admin/views/login.rs Normal file
View File

@@ -0,0 +1,114 @@
use maud::{html, Markup, DOCTYPE};
use axum::{Form, response::Redirect};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
pub async fn login_form() -> Markup {
render_login(None)
}
pub async fn login_post(Form(payload): Form<LoginPayload>) -> Result<Redirect, Markup> {
if payload.username == "admin" && payload.password == "admin" {
Ok(Redirect::to("/admin"))
} else {
Err(render_login(Some("Invalid username or password.")))
}
}
fn render_login(error: Option<&str>) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "SoundSonic Admin - Login" }
script src="https://cdn.tailwindcss.com" {}
script {
(maud::PreEscaped(r##"
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
},
fontFamily: { sans: ['Inter', 'sans-serif'] }
}
}
}
"##))
}
link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet";
script src="https://unpkg.com/@phosphor-icons/web" {}
style {
(maud::PreEscaped(r##"
body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
.glass-card {
background: rgba(30, 41, 59, 0.6);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
"##))
}
}
body class="min-h-screen flex items-center justify-center relative overflow-hidden antialiased selection:bg-primary-500 selection:text-white" {
// Background Glows
div class="absolute top-[20%] left-[20%] w-[30%] h-[30%] rounded-full bg-primary-900/30 blur-[120px] pointer-events-none" {}
div class="absolute bottom-[20%] right-[20%] w-[30%] h-[30%] rounded-full bg-blue-900/20 blur-[120px] pointer-events-none" {}
div class="w-full max-w-sm glass-card rounded-2xl p-8 z-10 relative" {
div class="flex flex-col items-center mb-8" {
div class="w-16 h-16 rounded-full bg-gradient-to-tr from-primary-500 to-indigo-600 flex items-center justify-center border-4 border-black/40 shadow-xl shadow-primary-500/20 mb-4" {
i class="ph-fill ph-waves text-3xl text-white" {}
}
h1 class="text-2xl font-bold text-white tracking-tight" { "SoundSonic Admin" }
p class="text-slate-400 text-sm mt-1" { "Sign in to access control center" }
}
@if let Some(msg) = error {
div class="bg-rose-500/10 border border-rose-500/20 rounded-lg p-3 mb-6 flex items-start gap-3" {
i class="ph-fill ph-warning-circle text-rose-400 text-lg mt-0.5" {}
p class="text-sm text-rose-200" { (msg) }
}
}
form action="/admin/login" method="POST" class="space-y-5" {
div {
label for="username" class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
div class="relative" {
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
i class="ph ph-user text-slate-500 text-lg" {}
}
input type="text" name="username" id="username" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="admin" {}
}
}
div {
div class="flex justify-between items-center mb-1.5" {
label for="password" class="block text-sm font-medium text-slate-300" { "Password" }
}
div class="relative" {
div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" {
i class="ph ph-lock-key text-slate-500 text-lg" {}
}
input type="password" name="password" id="password" required class="block w-full pl-10 pr-4 py-2.5 bg-black/40 border border-white/10 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all sm:text-sm" placeholder="••••••••" {}
}
}
button type="submit" class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm shadow-primary-900/20 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors mt-2" {
"Sign In"
}
}
}
}
}
}
}

135
src/admin/views/logs.rs Normal file
View File

@@ -0,0 +1,135 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn logs() -> Markup {
layout("Logs & Diagnostics", html! {
div class="flex flex-col gap-6 h-[calc(100vh-12rem)]" {
// Top Controls Row
div class="flex flex-col md:flex-row gap-4" {
// Filters
div class="glass-card rounded-xl border border-white/5 p-4 flex-1 flex flex-wrap gap-4 items-center" {
div class="flex items-center gap-2" {
i class="ph ph-funnel text-slate-400" {}
span class="text-sm font-medium text-slate-300" { "Filters:" }
}
select class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-slate-300 focus:outline-none focus:border-primary-500 transition-all text-sm appearance-none min-w-[120px]" {
option value="all" { "All Levels" }
option value="info" { "INFO only" }
option value="warn" { "WARN & Above" }
option value="error" { "ERROR only" }
}
input type="text" placeholder="Search logs..." class="bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary-500 transition-all text-sm min-w-[200px]" {}
div class="flex-1" {} // spacer
button class="bg-slate-800 hover:bg-slate-700 text-slate-300 border border-white/5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
i class="ph ph-download-simple" {} "Download Server Log"
}
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2" {
i class="ph ph-trash" {} "Clear"
}
}
}
// Layout Split: Log Viewer | Active Sessions
div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 min-h-0" {
// Log Viewer Panel (takes 2 cols)
div class="lg:col-span-2 glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
i class="ph ph-terminal-window text-emerald-400" {} "Live Tail"
}
div class="flex items-center gap-2" {
span class="relative flex h-2 w-2" {
span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" {}
span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" {}
}
span class="text-xs text-slate-500 font-medium tracking-wide uppercase" { "Auto-scrolling" }
}
}
div class="flex-1 p-4 bg-[#0a0f18] text-sm font-mono overflow-y-auto" {
div class="space-y-1" {
(log_line("14:02:11", "INFO", "text-blue-400", "Starting Subsonic API listener on 0.0.0.0:3311"))
(log_line("14:02:12", "INFO", "text-blue-400", "Connecting to SQLite database pool..."))
(log_line("14:02:12", "INFO", "text-blue-400", "Running pending migrations..."))
(log_line("14:05:43", "WARN", "text-amber-400", "Client 'DSub/5.4' requested unsupported downsample bitrates. Falling back to 128kbps."))
(log_line("14:09:01", "ERROR", "text-rose-400", "Failed to lookup LastFM metadata for artist 'Unknown': API timeout"))
(log_line("14:15:22", "INFO", "text-blue-400", "User 'admin' logged in from 192.168.1.5"))
(log_line("14:15:24", "INFO", "text-blue-400", "GET /rest/ping.view [200 OK] 4ms"))
(log_line("14:16:00", "INFO", "text-blue-400", "GET /rest/getIndexes.view [200 OK] 24ms"))
}
}
}
// Active Sessions Panel
div class="glass-card rounded-xl border border-white/5 flex flex-col overflow-hidden" {
div class="px-4 py-3 border-b border-white/5 bg-black/40 flex justify-between items-center" {
h3 class="text-sm font-medium text-slate-300 flex items-center gap-2" {
i class="ph ph-users-three text-primary-400" {} "Active Sessions"
}
span class="bg-primary-500/20 text-primary-400 text-xs font-bold px-2 py-0.5 rounded-full" { "2" }
}
div class="flex-1 overflow-y-auto divide-y divide-white/5" {
// Session item
div class="p-4 hover:bg-white/[0.02] transition-colors" {
div class="flex justify-between items-start mb-2" {
div class="flex items-center gap-2" {
div class="w-2 h-2 rounded-full bg-emerald-500" {}
span class="font-medium text-sm text-slate-200" { "admin" }
}
span class="text-xs text-slate-500" { "10m ago" }
}
div class="space-y-1 mb-3" {
p class="text-xs text-slate-400 flex items-center gap-2" {
i class="ph ph-laptop" {} "Web Browser (Chrome)"
}
p class="text-xs text-slate-400 font-mono" { "192.168.1.5" }
}
div class="flex gap-2" {
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
}
}
// Session item 2
div class="p-4 hover:bg-white/[0.02] transition-colors" {
div class="flex justify-between items-start mb-2" {
div class="flex items-center gap-2" {
div class="w-2 h-2 rounded-full bg-emerald-500" {}
span class="font-medium text-sm text-slate-200" { "guest" }
}
span class="text-xs text-slate-500" { "Just now" }
}
div class="space-y-1 mb-3" {
p class="text-xs text-slate-400 flex items-center gap-2" {
i class="ph ph-device-mobile" {} "DSub / 5.4.3"
}
p class="text-xs text-slate-400 font-mono" { "10.0.0.42" }
}
div class="flex gap-2" {
button class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-white/5" { "Kill Session" }
button class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-2 py-1.5 rounded text-xs font-medium transition-colors border border-rose-500/20" title="Ban IP" { i class="ph ph-prohibit" {} }
}
}
}
}
}
}
})
}
fn log_line(time: &str, level: &str, level_color: &str, msg: &str) -> Markup {
html! {
div class="flex gap-3 py-0.5 hover:bg-white/5 rounded px-1 transition-colors" {
span class="text-slate-500 shrink-0 w-20 text-xs" { (time) }
span class=(format!("shrink-0 w-12 text-xs font-bold {}", level_color)) { (level) }
span class="text-slate-300 break-all" { (msg) }
}
}
}

10
src/admin/views/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod about;
pub mod login;
pub mod clients;
pub mod dashboard;
pub mod jobs;
pub mod library;
pub mod logs;
pub mod playback;
pub mod settings;
pub mod users;

125
src/admin/views/playback.rs Normal file
View File

@@ -0,0 +1,125 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn playback() -> Markup {
layout("Playback & Streaming", html! {
div class="max-w-4xl mx-auto space-y-6" {
// Transcoding Settings
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-waveform text-purple-400" {} "Transcoding Rules"
}
}
div class="p-6 space-y-6 form-group" {
label class="flex items-center gap-4 cursor-pointer" {
div class="relative flex items-center" {
input type="checkbox" checked class="peer sr-only" {}
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-purple-500 peer-checked:border-purple-400 transition-all" {}
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
}
span class="text-sm font-medium text-slate-300" { "Enable Server-side Transcoding" }
}
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Default Max Bitrate" }
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
option value="0" { "No limit (Original)" }
option value="320" selected { "320 kbps" }
option value="256" { "256 kbps" }
option value="192" { "192 kbps" }
}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Transcode Format Preference" }
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
option value="mp3" { "MP3 (Maximum Compatibility)" }
option value="opus" selected { "Opus (Best Quality/Size ratio)" }
option value="aac" { "AAC (Apple Devices)" }
}
}
}
}
}
// Streaming Settings
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-broadcast text-sky-400" {} "Streaming Buffers"
}
}
div class="p-6 space-y-6" {
div class="grid grid-cols-1 md:grid-cols-3 gap-6" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Chunk Size"
span class="text-xs text-slate-500" { "MB" }
}
input type="number" value="1" min="1" max="10" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Buffer Threshold"
span class="text-xs text-slate-500" { "Seconds" }
}
input type="number" value="10" min="5" max="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Preload Next Track"
span class="text-xs text-slate-500" { "Seconds" }
}
input type="number" value="15" min="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
}
}
}
// API Compatibility
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-plugs-connected text-amber-400" {} "Subsonic API Compatibility"
}
}
div class="p-6 space-y-4" {
div class="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 p-4 rounded-lg" {
i class="ph ph-info text-amber-500 text-xl shrink-0" {}
p class="text-sm text-amber-200/80" { "Changes here affect how third-party apps like DSub, Play:Sub, or Ultrasonic parse server responses." }
}
label class="flex items-center gap-4 cursor-pointer mt-4" {
div class="relative flex items-center" {
input type="checkbox" class="peer sr-only" {}
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
}
span class="text-sm font-medium text-slate-300" { "Strict API Mode" }
}
p class="text-xs text-slate-500 ml-15 pl-1" { "When enabled, drops unsupported legacy fields from JSON responses. May break older clients." }
div class="mt-4" {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Reported API Version" }
select class="w-full md:w-1/2 bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm appearance-none" {
option value="1.16.1" selected { "OpenSubsonic 1.16.1 (Modern)" }
option value="1.15.0" { "Subsonic 1.15.0 (Legacy Default)" }
option value="1.13.0" { "Subsonic 1.13.0 (Old Clients)" }
}
}
}
}
div class="flex justify-end" {
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
i class="ph ph-floppy-disk text-lg" {} "Save Playback Options"
}
}
}
})
}

120
src/admin/views/settings.rs Normal file
View File

@@ -0,0 +1,120 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn settings() -> Markup {
layout("System Settings", html! {
div class="max-w-4xl mx-auto space-y-6" {
// Server Network Base Settings
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-globe text-primary-400" {} "Network & Domain"
}
}
div class="p-6 space-y-6" {
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="md:col-span-2" {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Public Server URL" }
input type="url" value="https://music.home.arpa" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
p class="text-xs text-slate-500 mt-1.5" { "Used for generating share links and podcast enclosures." }
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Bind Port" }
input type="number" value="3311" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm font-mono" {}
}
div class="flex items-end pb-2" {
label class="flex items-center gap-4 cursor-pointer" {
div class="relative flex items-center" {
input type="checkbox" checked class="peer sr-only" {}
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-primary-500 peer-checked:border-primary-400 transition-all" {}
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
}
span class="text-sm font-medium text-slate-300" { "Trust Proxy Headers (X-Forwarded-For)" }
}
}
}
}
}
// Security Settings
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-shield-check text-rose-400" {} "Security Limits"
}
}
div class="p-6 space-y-6" {
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="flex flex-col justify-center" {
label class="flex items-center gap-4 cursor-pointer" {
div class="relative flex items-center" {
input type="checkbox" class="peer sr-only" {}
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-rose-500 peer-checked:border-rose-400 transition-all" {}
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm" {}
}
span class="text-sm font-medium text-slate-300" { "Allow Public Registration" }
}
p class="text-xs text-slate-500 ml-15 pl-1 mt-1" { "Users can self-provision accounts without an invite." }
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Session Timeout"
span class="text-xs text-slate-500" { "Days" }
}
input type="number" value="30" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Failed Login Threshold" }
input type="number" value="5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Lockout Duration"
span class="text-xs text-slate-500" { "Minutes" }
}
input type="number" value="15" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
}
}
}
// Storage Locations
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="px-6 py-4 border-b border-white/5 bg-black/20" {
h2 class="text-lg font-medium text-white flex items-center gap-2" {
i class="ph ph-database text-emerald-400" {} "Internal Storage"
}
}
div class="p-6 space-y-6" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Temp Directory" }
input type="text" value="/tmp/soundsonic" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all font-mono text-sm" {}
p class="text-xs text-slate-500 mt-1.5" { "Used for uploading chunks, transcoding frames, and zipping downloads." }
}
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Artwork Cache Size Limit"
span class="text-xs text-slate-500" { "GB" }
}
input type="number" value="1" step="0.5" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all text-sm" {}
}
}
}
}
div class="flex justify-end gap-4" {
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
i class="ph ph-floppy-disk text-lg" {} "Save Settings"
}
}
}
})
}

243
src/admin/views/users.rs Normal file
View File

@@ -0,0 +1,243 @@
use maud::{html, Markup};
use crate::admin::layout::layout;
pub async fn users() -> Markup {
layout("Users Management", html! {
div class="flex justify-between items-center mb-6" {
div {
h2 class="text-lg font-medium text-white" { "All Users" }
p class="text-sm text-slate-400 mt-1" { "Manage server access, roles, and capabilities." }
}
a href="/admin/users/new" class="bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
i class="ph ph-plus-circle text-lg" {}
"Create User"
}
}
div class="glass-card rounded-xl border border-white/5 overflow-hidden" {
div class="overflow-x-auto" {
table class="w-full text-left border-collapse" {
thead class="bg-black/30 text-xs uppercase tracking-wider text-slate-400" {
tr {
th class="px-6 py-4 font-semibold" { "User" }
th class="px-6 py-4 font-semibold" { "Roles & Access" }
th class="px-6 py-4 font-semibold" { "Last Seen" }
th class="px-6 py-4 font-semibold w-24" { "Status" }
th class="px-6 py-4 font-semibold text-right" { "Actions" }
}
}
tbody class="divide-y divide-white/5 text-sm" {
// Admin User Row
tr class="hover:bg-white/[0.02] transition-colors" {
td class="px-6 py-4" {
div class="flex items-center gap-3" {
div class="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-500 to-primary-700 flex items-center justify-center text-xs font-bold shadow-lg shadow-primary-500/20" { "A" }
div {
div class="font-medium text-white flex items-center gap-2" {
"admin"
i class="ph-fill ph-check-circle text-emerald-400 text-xs" title="Verified" {}
}
div class="text-xs text-slate-500 mt-0.5" { "admin@local" }
}
}
}
td class="px-6 py-4" {
div class="flex flex-wrap gap-2" {
span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 text-[10px] font-bold tracking-wider" { "ADMIN" }
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Download" }
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Upload" }
}
}
td class="px-6 py-4 text-slate-400 text-xs" {
"Active now"
}
td class="px-6 py-4" {
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" {
div class="w-1.5 h-1.5 rounded-full bg-emerald-400" {} "Active"
}
}
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
a href="/admin/users/1/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Disable" { i class="ph ph-prohibit" {} }
}
}
// Guest User Row
tr class="hover:bg-white/[0.02] transition-colors" {
td class="px-6 py-4" {
div class="flex items-center gap-3" {
div class="w-8 h-8 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-xs font-bold text-slate-400" { "G" }
div {
div class="font-medium text-white" { "guest" }
}
}
}
td class="px-6 py-4" {
div class="flex flex-wrap gap-2" {
span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-white/10 text-[10px] uppercase font-medium tracking-wider" { "Stream" }
}
}
td class="px-6 py-4 text-slate-400 text-xs" {
"2 days ago"
}
td class="px-6 py-4" {
span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-slate-800 text-slate-400 border border-slate-700" {
div class="w-1.5 h-1.5 rounded-full bg-slate-500" {} "Offline"
}
}
td class="px-6 py-4 text-right space-x-3 text-lg font-medium" {
a href="/admin/users/2/edit" class="text-slate-400 hover:text-primary-400 transition-colors" title="Edit User" { i class="ph ph-note-pencil" {} }
button class="text-slate-400 hover:text-amber-400 transition-colors" title="Impersonate" { i class="ph ph-mask-happy" {} }
button class="text-slate-400 hover:text-rose-400 transition-colors" title="Delete" { i class="ph ph-trash" {} }
}
}
}
}
}
}
})
}
pub async fn user_create() -> Markup {
user_form_layout("Create New User", true)
}
pub async fn user_edit() -> Markup {
user_form_layout("Edit User: admin", false)
}
fn user_form_layout(title_text: &str, is_new: bool) -> Markup {
layout(title_text, html! {
div class="max-w-4xl mx-auto" {
// Header actions
div class="flex items-center gap-4 mb-6" {
a href="/admin/users" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors border border-white/5" {
i class="ph ph-arrow-left text-lg" {}
}
div {
h2 class="text-xl font-medium text-white" { (title_text) }
p class="text-sm text-slate-400 mt-1" { "Configure account credentials, limits, and server permissions." }
}
}
form class="space-y-6" {
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
// Left Column: Credentials & Limits
div class="space-y-6 flex flex-col" {
div class="glass-card rounded-xl p-6 border border-white/5" {
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Credentials" }
div class="space-y-4" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Username" }
input type="text" value=(if!is_new{"admin"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all font-mono text-sm" placeholder="e.g. music_lover" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" {
"Password "
@if !is_new { span class="text-xs text-slate-500 font-normal ml-1" { "(Leave blank to keep unchanged)" } }
}
input type="password" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="••••••••" {}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5" { "Email (Optional)" }
input type="email" value=(if!is_new{"admin@local"}else{""}) class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" placeholder="user@domain.com" {}
}
}
}
div class="glass-card rounded-xl p-6 border border-white/5 flex-1" {
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5" { "Limits & Quotas" }
div class="space-y-4" {
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Max Audio Bitrate"
span class="text-slate-500 font-normal text-xs" { "Kbps" }
}
select class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm appearance-none" {
option value="0" { "Unlimited (Direct Stream)" }
option value="320" { "320 kbps (High)" }
option value="192" { "192 kbps (Standard)" }
option value="128" { "128 kbps (Low)" }
option value="64" { "64 kbps (Cellular)" }
}
}
div {
label class="block text-sm font-medium text-slate-300 mb-1.5 flex justify-between" {
"Storage Quota"
span class="text-slate-500 font-normal text-xs" { "GB" }
}
input type="number" min="0" value="0" class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500 transition-all text-sm" {}
p class="text-xs text-slate-500 mt-2" { "Set to 0 for unlimited storage (if Upload is allowed)." }
}
}
}
}
// Right Column: Permissions
div class="glass-card rounded-xl p-6 border border-white/5 h-full" {
h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 mb-4 pb-3 border-b border-white/5 flex items-center justify-between" {
"Permissions"
i class="ph ph-shield-check text-lg text-primary-400" {}
}
div class="space-y-4" {
// Admin Toggle
label class="flex items-start gap-4 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 cursor-pointer hover:bg-amber-500/20 transition-colors group" {
div class="relative flex items-center" {
input type="checkbox" checked[(!is_new)] class="peer sr-only" {}
div class="w-11 h-6 bg-slate-800 rounded-full border border-white/10 peer-checked:bg-amber-500 peer-checked:border-amber-400 transition-all" {}
div class="absolute left-[3px] top-[3px] w-4.5 h-4.5 bg-white rounded-full transition-all peer-checked:translate-x-[20px] shadow-sm shadow-black/50" {}
}
div class="flex-1" {
p class="text-sm font-medium text-amber-500 group-hover:text-amber-400 transition-colors" { "Administrator Privileges" }
p class="text-xs text-amber-500/70 mt-1" { "Grants full access to modify server settings, manage users, and view all system logs." }
}
}
hr class="border-white/5 my-4" {}
// Capability Toggles
(capability_toggle("Stream Media", "Stream music and video", true, "ph-play-circle"))
(capability_toggle("Download Files", "Download original media files", true, "ph-download-simple"))
(capability_toggle("Upload Media", "Upload files and modify library files", !is_new, "ph-upload-simple"))
(capability_toggle("Create Playlists", "Create, edit, and delete playlists", true, "ph-list-dashes"))
(capability_toggle("Share Links", "Create public sharing links for media", !is_new, "ph-share-network"))
(capability_toggle("Transcoding", "Allow server-side format conversion", true, "ph-waveform"))
(capability_toggle("Folder Access", "See raw folder structure not just tags", true, "ph-folder"))
}
}
}
// Footer Buttons
div class="flex items-center justify-end gap-4 mt-8 pt-6 border-t border-white/5" {
a href="/admin/users" class="px-5 py-2.5 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors" { "Cancel" }
button type="button" class="bg-primary-600 hover:bg-primary-500 text-white px-6 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-primary-900/20 flex items-center gap-2" {
i class="ph ph-floppy-disk text-lg" {}
"Save User"
}
}
}
}
})
}
fn capability_toggle(title: &str, desc: &str, checked_by_default: bool, icon: &str) -> Markup {
html! {
label class="flex items-start gap-4 p-3 rounded-lg border border-transparent hover:bg-white/5 hover:border-white/10 cursor-pointer transition-all" {
div class="relative flex items-center shrink-0 mt-0.5" {
input type="checkbox" checked class="peer w-4 h-4 rounded bg-slate-800 border-white/20 text-primary-500 focus:ring-primary-500/50 appearance-none checked:bg-primary-500 checked:border-primary-500 transition-all relative overflow-hidden flex items-center justify-center
after:content-[''] after:absolute after:w-2 after:h-2.5 after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:-translate-y-0.5 after:opacity-0 peer-checked:after:opacity-100" {}
}
div class="flex-1 flex gap-3" {
i class=(format!("ph {} text-xl text-slate-400 mt-0.5", icon)) {}
div {
p class="text-sm font-medium text-slate-200" { (title) }
p class="text-xs text-slate-500 mt-0.5" { (desc) }
}
}
}
}
}

1
src/db/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod models;

59
src/db/models.rs Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod system_router;
pub mod user_management_router;

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

View 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(&params.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
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod system;
pub mod types;

23
src/types/system.rs Normal file
View 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
View 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,
})),
}
}
}