Compare commits

..

3 Commits

Author SHA1 Message Date
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
21 changed files with 1682 additions and 2 deletions

2
.gitignore vendored Normal file
View File

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

1091
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[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 = "0.3.22"

150
README.md
View File

@@ -1,3 +1,149 @@
# soundsonic # SoundSonic
open subsonic api implementation A Rust-based music streaming server implementing the OpenSubsonic API protocol.
## Overview
SoundSonic is a lightweight music streaming server built with Rust that implements the [OpenSubsonic](https://opensubsonic.netlify.app/) API specification. It allows you to stream your music collection to any OpenSubsonic-compatible client.
## Features
- **OpenSubsonic API Support**: Compatible with any client supporting the OpenSubsonic protocol
- **User Management**: Role-based access control with customizable user permissions
- **SQLite Database**: Lightweight local storage with Diesel ORM
- **RESTful API**: Clean, well-structured API endpoints
- **Async Runtime**: Built on Tokio for high performance
## Technologies
- **Rust** (Edition 2024)
- **Axum** - Web framework
- **Tokio** - Async runtime
- **Diesel** - ORM with SQLite
- **Serde** - Serialization/deserialization
## Prerequisites
- Rust (latest stable version)
- SQLite
- Diesel CLI (for database migrations)
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd soundsonic
```
2. Set up the database URL:
```bash
export DATABASE_URL=database.db
```
Or create a `.env` file:
```
DATABASE_URL=database.db
```
3. Run database migrations:
```bash
diesel migration run
```
4. Build and run the server:
```bash
cargo run
```
The server will start on `http://0.0.0.0:3311`
## API Endpoints
### System
- `GET/POST /rest/getOpenSubsonicExtensions` - Get supported OpenSubsonic extensions
### User Management
- `GET/POST /rest/createUser.view` - Create a new user with specified permissions
### 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
[Your License Here]
## 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"

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,
}

28
src/main.rs Normal file
View File

@@ -0,0 +1,28 @@
mod db;
mod routes;
mod schema;
mod state;
mod types;
use std::env;
use axum::Router;
use dotenvy::dotenv;
use crate::routes::system_router::system_routers;
use crate::routes::user_management_router::user_management_routs;
use crate::state::AppState;
#[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::fmt::init();
let app = Router::new()
.nest("/rest", system_routers().merge(user_management_routs()))
.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,17 @@
use crate::state::AppState;
use crate::types::types::OpenSubSonicResponses;
use axum::{Json, Router, http::StatusCode, routing::get};
pub fn system_routers() -> Router<AppState> {
Router::new().route(
"/getOpenSubsonicExtensions",
get(get_opensubsonic_extentions).post(get_opensubsonic_extentions),
)
}
async fn get_opensubsonic_extentions() -> (StatusCode, Json<OpenSubSonicResponses>) {
(
StatusCode::OK,
Json(OpenSubSonicResponses::open_subsonic_extentions()),
)
}

View File

@@ -0,0 +1,56 @@
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> {
let userm_routs = Router::new().route("/createUser.view", get(create_user).post(create_user));
return userm_routs;
}
fn check_if_user_exist(user_name: &String, db_con: &AppState) -> bool {
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()),
);
};
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;

12
src/types/system.rs Normal file
View File

@@ -0,0 +1,12 @@
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>,
}

155
src/types/types.rs Normal file
View File

@@ -0,0 +1,155 @@
use serde::{Deserialize, Serialize};
use crate::types::system::{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(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")]
pub struct ErrorResponse {
code: u8,
message: String,
}
#[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>,
},
}
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 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 {
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_extentions() -> 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,
})),
}
}
}