Minimal subsonic functionality
This commit is contained in:
@@ -23,6 +23,10 @@ actix-cors = "0.7"
|
||||
actix-files = "0.6"
|
||||
actix-session = { version = "0.10", features = ["cookie-session"] }
|
||||
argon2 = "0.5"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
serde_urlencoded = "0.7"
|
||||
rand = "0.9"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
@@ -32,6 +36,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-actix-web = "0.7"
|
||||
|
||||
@@ -326,14 +326,8 @@ pub async fn add_track_to_playlist(
|
||||
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||
}
|
||||
|
||||
pub async fn remove_track_from_playlist(
|
||||
playlist_id: i32,
|
||||
track_id: i32,
|
||||
) -> Result<(), ApiError> {
|
||||
delete(&format!(
|
||||
"{BASE}/playlists/{playlist_id}/tracks/{track_id}"
|
||||
))
|
||||
.await
|
||||
pub async fn remove_track_from_playlist(playlist_id: i32, track_id: i32) -> Result<(), ApiError> {
|
||||
delete(&format!("{BASE}/playlists/{playlist_id}/tracks/{track_id}")).await
|
||||
}
|
||||
|
||||
pub async fn reorder_playlist_tracks(
|
||||
@@ -348,6 +342,17 @@ pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
|
||||
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
|
||||
}
|
||||
|
||||
// --- Subsonic ---
|
||||
|
||||
pub async fn get_subsonic_password_status() -> Result<SubsonicPasswordStatus, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/subsonic-password-status")).await
|
||||
}
|
||||
|
||||
pub async fn set_subsonic_password(password: &str) -> Result<serde_json::Value, ApiError> {
|
||||
let body = serde_json::json!({"password": password}).to_string();
|
||||
put_json(&format!("{BASE}/auth/subsonic-password"), &body).await
|
||||
}
|
||||
|
||||
// --- YouTube Auth ---
|
||||
|
||||
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
||||
|
||||
@@ -560,16 +560,8 @@ pub fn playlists_page() -> Html {
|
||||
if existing_ids.contains(&t.id) {
|
||||
return false;
|
||||
}
|
||||
let title_lower = t
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let artist_lower = t
|
||||
.artist
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let title_lower = t.title.as_deref().unwrap_or("").to_lowercase();
|
||||
let artist_lower = t.artist.as_deref().unwrap_or("").to_lowercase();
|
||||
// Subsequence match on title or artist
|
||||
let matches_field = |field: &str| {
|
||||
let mut chars = search_query.chars();
|
||||
|
||||
@@ -3,7 +3,7 @@ use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::types::{AppConfig, YtAuthStatus};
|
||||
use crate::types::{AppConfig, SubsonicPasswordStatus, YtAuthStatus};
|
||||
|
||||
#[function_component(SettingsPage)]
|
||||
pub fn settings_page() -> Html {
|
||||
@@ -12,11 +12,15 @@ pub fn settings_page() -> Html {
|
||||
let message = use_state(|| None::<String>);
|
||||
let ytauth = use_state(|| None::<YtAuthStatus>);
|
||||
let ytauth_loading = use_state(|| false);
|
||||
let subsonic_status = use_state(|| None::<SubsonicPasswordStatus>);
|
||||
let subsonic_password = use_state(String::new);
|
||||
let subsonic_saving = use_state(|| false);
|
||||
|
||||
{
|
||||
let config = config.clone();
|
||||
let error = error.clone();
|
||||
let ytauth = ytauth.clone();
|
||||
let subsonic_status = subsonic_status.clone();
|
||||
use_effect_with((), move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::get_config().await {
|
||||
@@ -29,6 +33,11 @@ pub fn settings_page() -> Html {
|
||||
ytauth.set(Some(status));
|
||||
}
|
||||
});
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Ok(status) = api::get_subsonic_password_status().await {
|
||||
subsonic_status.set(Some(status));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,6 +509,93 @@ pub fn settings_page() -> Html {
|
||||
{ ytauth_html }
|
||||
</div>
|
||||
|
||||
// Subsonic API
|
||||
<div class="card">
|
||||
<h3>{ "Subsonic API" }</h3>
|
||||
<p class="text-sm text-muted mb-1">
|
||||
{ "Connect Subsonic-compatible apps (DSub, Symfonium, Feishin) to stream your library. " }
|
||||
{ "This is a minimal Subsonic implementation for basic browsing and playback. " }
|
||||
{ "For a full-featured Subsonic server, consider " }
|
||||
<a href="https://www.navidrome.org" target="_blank">{ "Navidrome" }</a>
|
||||
{ " pointed at the same library." }
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>{ "Server URL (most clients add /rest automatically)" }</label>
|
||||
<input type="text" readonly=true value={
|
||||
format!("http://{}:{}",
|
||||
c.web.bind.clone(),
|
||||
c.web.port)
|
||||
} />
|
||||
</div>
|
||||
{
|
||||
if let Some(ref status) = *subsonic_status {
|
||||
if status.set {
|
||||
html! {
|
||||
<p class="text-sm">
|
||||
<span class="badge badge-success">{ "Password set" }</span>
|
||||
</p>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<p class="text-sm text-muted">{ "No Subsonic password set. Set one below to enable access." }</p>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
|
||||
}
|
||||
}
|
||||
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
|
||||
<p class="text-sm" style="margin:0;">
|
||||
<strong style="color: var(--warning);">{ "Warning: " }</strong>
|
||||
{ "This password is stored in plaintext per the Subsonic protocol. Do " }
|
||||
<strong>{ "not" }</strong>
|
||||
{ " reuse a password from another account." }
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Subsonic Password" }</label>
|
||||
<input type="password" placeholder="Enter Subsonic password"
|
||||
value={(*subsonic_password).clone()}
|
||||
oninput={let subsonic_password = subsonic_password.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
subsonic_password.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary"
|
||||
disabled={*subsonic_saving || subsonic_password.is_empty()}
|
||||
onclick={{
|
||||
let subsonic_password = subsonic_password.clone();
|
||||
let subsonic_saving = subsonic_saving.clone();
|
||||
let subsonic_status = subsonic_status.clone();
|
||||
let message = message.clone();
|
||||
let error = error.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let pw = (*subsonic_password).clone();
|
||||
let subsonic_saving = subsonic_saving.clone();
|
||||
let subsonic_status = subsonic_status.clone();
|
||||
let subsonic_password = subsonic_password.clone();
|
||||
let message = message.clone();
|
||||
let error = error.clone();
|
||||
subsonic_saving.set(true);
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::set_subsonic_password(&pw).await {
|
||||
Ok(_) => {
|
||||
message.set(Some("Subsonic password saved".into()));
|
||||
subsonic_password.set(String::new());
|
||||
if let Ok(s) = api::get_subsonic_password_status().await {
|
||||
subsonic_status.set(Some(s));
|
||||
}
|
||||
}
|
||||
Err(e) => error.set(Some(e.0)),
|
||||
}
|
||||
subsonic_saving.set(false);
|
||||
});
|
||||
})
|
||||
}}>
|
||||
{ if *subsonic_saving { "Saving..." } else { "Save Subsonic Password" } }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Metadata Providers
|
||||
<div class="card">
|
||||
<h3>{ "Metadata Providers" }</h3>
|
||||
|
||||
@@ -371,6 +371,13 @@ pub struct SavedPlaylist {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// --- Subsonic ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct SubsonicPasswordStatus {
|
||||
pub set: bool,
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -126,15 +126,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
.service(
|
||||
actix_files::Files::new("/", static_dir.clone())
|
||||
.index_file("index.html")
|
||||
.prefer_utf8(true),
|
||||
.prefer_utf8(true)
|
||||
.guard(actix_web::guard::fn_guard(|ctx| {
|
||||
!ctx.head().uri.path().starts_with("/rest")
|
||||
})),
|
||||
)
|
||||
// SPA fallback: serve index.html for any route not matched
|
||||
// by API or static files, so client-side routing works on refresh
|
||||
// by API or static files, so client-side routing works on refresh.
|
||||
// /rest/* paths get a Subsonic error instead of index.html.
|
||||
.default_service(web::to({
|
||||
let index_path = static_dir.join("index.html");
|
||||
move |req: actix_web::HttpRequest| {
|
||||
let index_path = index_path.clone();
|
||||
async move {
|
||||
if req.path().starts_with("/rest") {
|
||||
return Ok(actix_web::HttpResponse::NotFound()
|
||||
.content_type("application/json")
|
||||
.body(r#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":0,"message":"Unknown endpoint"}}}"#));
|
||||
}
|
||||
actix_files::NamedFile::open_async(index_path)
|
||||
.await
|
||||
.map(|f| f.into_response(&req))
|
||||
|
||||
@@ -20,7 +20,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::get().to(list_users))
|
||||
.route(web::post().to(create_user)),
|
||||
)
|
||||
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)));
|
||||
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)))
|
||||
.service(
|
||||
web::resource("/auth/subsonic-password").route(web::put().to(set_subsonic_password)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/auth/subsonic-password-status")
|
||||
.route(web::get().to(subsonic_password_status)),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -41,6 +48,11 @@ struct CreateUserRequest {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubsonicPasswordRequest {
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// Check if initial setup is required (no users in database).
|
||||
async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
let count = queries::users::count(state.db.conn()).await?;
|
||||
@@ -205,3 +217,34 @@ async fn delete_user(
|
||||
tracing::info!(user_id = user_id, "user deleted by admin");
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Set the Subsonic password for the current user.
|
||||
async fn set_subsonic_password(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<SubsonicPasswordRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||
|
||||
if body.password.len() < 4 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"password must be at least 4 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
queries::users::set_subsonic_password(state.db.conn(), user_id, &body.password).await?;
|
||||
tracing::info!(user_id = user_id, "subsonic password set");
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ok" })))
|
||||
}
|
||||
|
||||
/// Check whether the current user has a Subsonic password set.
|
||||
async fn subsonic_password_status(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||
let user = queries::users::get_by_id(state.db.conn(), user_id).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"set": user.subsonic_password.is_some(),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod downloads;
|
||||
pub mod lyrics;
|
||||
pub mod playlists;
|
||||
pub mod search;
|
||||
pub mod subsonic;
|
||||
pub mod system;
|
||||
pub mod tracks;
|
||||
pub mod ytauth;
|
||||
@@ -25,4 +26,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.configure(ytauth::configure)
|
||||
.configure(playlists::configure),
|
||||
);
|
||||
// Subsonic API at /rest/*
|
||||
subsonic::configure(cfg);
|
||||
}
|
||||
|
||||
42
src/routes/subsonic/annotation.rs
Normal file
42
src/routes/subsonic/annotation.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||
use super::response;
|
||||
|
||||
/// GET /rest/scrobble[.view]
|
||||
pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid id");
|
||||
}
|
||||
};
|
||||
|
||||
// Log the scrobble for now; full play tracking can be added later
|
||||
tracing::info!(
|
||||
user = %user.username,
|
||||
id_type = prefix,
|
||||
id = entity_id,
|
||||
"subsonic scrobble"
|
||||
);
|
||||
|
||||
response::ok(¶ms.format, serde_json::json!({}))
|
||||
}
|
||||
126
src/routes/subsonic/auth.rs
Normal file
126
src/routes/subsonic/auth.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use actix_web::HttpRequest;
|
||||
use md5::{Digest, Md5};
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use shanty_db::entities::user::Model as User;
|
||||
use shanty_db::queries;
|
||||
|
||||
/// Subsonic authentication method.
|
||||
pub enum AuthMethod {
|
||||
/// Modern: token = md5(password + salt)
|
||||
Token { token: String, salt: String },
|
||||
/// Legacy: plaintext password
|
||||
Password(String),
|
||||
/// Legacy: hex-encoded password (p=enc:hexstring)
|
||||
HexPassword(String),
|
||||
}
|
||||
|
||||
/// Common Subsonic API parameters extracted from the query string.
|
||||
pub struct SubsonicParams {
|
||||
/// Username
|
||||
pub username: String,
|
||||
/// Authentication method + credentials
|
||||
pub auth: AuthMethod,
|
||||
/// API version requested
|
||||
#[allow(dead_code)]
|
||||
pub version: String,
|
||||
/// Client name
|
||||
#[allow(dead_code)]
|
||||
pub client: String,
|
||||
/// Response format: "xml" or "json"
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
pub enum SubsonicAuthError {
|
||||
MissingParam(String),
|
||||
AuthFailed,
|
||||
}
|
||||
|
||||
impl SubsonicParams {
|
||||
/// Extract Subsonic params from the query string.
|
||||
pub fn from_request(req: &HttpRequest) -> Result<Self, SubsonicAuthError> {
|
||||
let qs = req.query_string();
|
||||
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||
|
||||
let get = |name: &str| -> Option<String> {
|
||||
params
|
||||
.iter()
|
||||
.find(|(k, _)| k == name)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
|
||||
let username = get("u").ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?;
|
||||
let version = get("v").unwrap_or_else(|| "1.16.1".into());
|
||||
let client = get("c").unwrap_or_else(|| "unknown".into());
|
||||
let format = get("f").unwrap_or_else(|| "xml".into());
|
||||
|
||||
// Try token auth first (modern), then legacy password
|
||||
let auth = if let (Some(token), Some(salt)) = (get("t"), get("s")) {
|
||||
AuthMethod::Token { token, salt }
|
||||
} else if let Some(p) = get("p") {
|
||||
if let Some(hex_str) = p.strip_prefix("enc:") {
|
||||
AuthMethod::HexPassword(hex_str.to_string())
|
||||
} else {
|
||||
AuthMethod::Password(p)
|
||||
}
|
||||
} else {
|
||||
return Err(SubsonicAuthError::MissingParam(
|
||||
"authentication required (t+s or p)".into(),
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
auth,
|
||||
version,
|
||||
client,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify Subsonic authentication against the stored subsonic_password.
|
||||
pub async fn verify_auth(
|
||||
db: &DatabaseConnection,
|
||||
params: &SubsonicParams,
|
||||
) -> Result<User, SubsonicAuthError> {
|
||||
let user = queries::users::find_by_username(db, ¶ms.username)
|
||||
.await
|
||||
.map_err(|_| SubsonicAuthError::AuthFailed)?
|
||||
.ok_or(SubsonicAuthError::AuthFailed)?;
|
||||
|
||||
let subsonic_password = user
|
||||
.subsonic_password
|
||||
.as_deref()
|
||||
.ok_or(SubsonicAuthError::AuthFailed)?;
|
||||
|
||||
match ¶ms.auth {
|
||||
AuthMethod::Token { token, salt } => {
|
||||
// Compute md5(password + salt) and compare
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(subsonic_password.as_bytes());
|
||||
hasher.update(salt.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let expected = hex::encode(result);
|
||||
if expected != *token {
|
||||
return Err(SubsonicAuthError::AuthFailed);
|
||||
}
|
||||
}
|
||||
AuthMethod::Password(password) => {
|
||||
// Direct plaintext comparison
|
||||
if password != subsonic_password {
|
||||
return Err(SubsonicAuthError::AuthFailed);
|
||||
}
|
||||
}
|
||||
AuthMethod::HexPassword(hex_str) => {
|
||||
// Decode hex to string, compare
|
||||
let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?;
|
||||
let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?;
|
||||
if password != subsonic_password {
|
||||
return Err(SubsonicAuthError::AuthFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
589
src/routes/subsonic/browsing.rs
Normal file
589
src/routes/subsonic/browsing.rs
Normal file
@@ -0,0 +1,589 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||
use super::response::{self, SubsonicChild};
|
||||
|
||||
/// GET /rest/getMusicFolders[.view]
|
||||
pub async fn get_music_folders(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"musicFolders": {
|
||||
"musicFolder": [
|
||||
{ "id": 1, "name": "Music" }
|
||||
]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getArtists[.view]
|
||||
pub async fn get_artists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_GENERIC,
|
||||
&format!("database error: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Group artists by first letter for the index
|
||||
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||
for artist in &artists {
|
||||
let first_char = artist
|
||||
.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('#')
|
||||
.to_uppercase()
|
||||
.next()
|
||||
.unwrap_or('#');
|
||||
let key = if first_char.is_alphabetic() {
|
||||
first_char.to_string()
|
||||
} else {
|
||||
"#".to_string()
|
||||
};
|
||||
|
||||
// Count albums for this artist
|
||||
let album_count = queries::albums::get_by_artist(state.db.conn(), artist.id)
|
||||
.await
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
index_map.entry(key).or_default().push(serde_json::json!({
|
||||
"id": format!("ar-{}", artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": album_count,
|
||||
}));
|
||||
}
|
||||
|
||||
let indices: Vec<serde_json::Value> = index_map
|
||||
.into_iter()
|
||||
.map(|(name, artists)| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"artist": artists,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"artists": {
|
||||
"ignoredArticles": "The El La Los Las Le Les",
|
||||
"index": indices,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getArtist[.view]
|
||||
pub async fn get_artist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, artist_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid artist id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let artist = match queries::artists::get_by_id(state.db.conn(), artist_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"artist not found",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let albums = queries::albums::get_by_artist(state.db.conn(), artist_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut album_list: Vec<serde_json::Value> = Vec::new();
|
||||
for album in &albums {
|
||||
let track_count = queries::tracks::get_by_album(state.db.conn(), album.id)
|
||||
.await
|
||||
.map(|t| t.len())
|
||||
.unwrap_or(0);
|
||||
let duration: i32 = queries::tracks::get_by_album(state.db.conn(), album.id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||
.sum();
|
||||
let mut album_json = serde_json::json!({
|
||||
"id": format!("al-{}", album.id),
|
||||
"name": album.name,
|
||||
"title": album.name,
|
||||
"artist": if album.album_artist.is_empty() { &artist.name } else { &album.album_artist },
|
||||
"artistId": format!("ar-{}", artist.id),
|
||||
"coverArt": format!("al-{}", album.id),
|
||||
"songCount": track_count,
|
||||
"duration": duration,
|
||||
"created": "2024-01-01T00:00:00",
|
||||
});
|
||||
// Only include year/genre if present (avoid nulls)
|
||||
if let Some(year) = album.year {
|
||||
album_json["year"] = serde_json::json!(year);
|
||||
}
|
||||
if let Some(ref genre) = album.genre {
|
||||
album_json["genre"] = serde_json::json!(genre);
|
||||
}
|
||||
album_list.push(album_json);
|
||||
}
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"artist": {
|
||||
"id": format!("ar-{}", artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": albums.len(),
|
||||
"album": album_list,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getAlbum[.view]
|
||||
pub async fn get_album(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, album_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid album id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let album = match queries::albums::get_by_id(state.db.conn(), album_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "album not found");
|
||||
}
|
||||
};
|
||||
|
||||
let tracks = queries::tracks::get_by_album(state.db.conn(), album_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let total_duration: i32 = tracks
|
||||
.iter()
|
||||
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||
.sum();
|
||||
|
||||
let song_list: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
let mut album_json = serde_json::json!({
|
||||
"id": format!("al-{}", album.id),
|
||||
"name": album.name,
|
||||
"title": album.name,
|
||||
"artist": album.album_artist,
|
||||
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
|
||||
"coverArt": format!("al-{}", album.id),
|
||||
"songCount": tracks.len(),
|
||||
"duration": total_duration,
|
||||
"created": "2024-01-01T00:00:00",
|
||||
"song": song_list,
|
||||
});
|
||||
if let Some(year) = album.year {
|
||||
album_json["year"] = serde_json::json!(year);
|
||||
}
|
||||
if let Some(ref genre) = album.genre {
|
||||
album_json["genre"] = serde_json::json!(genre);
|
||||
}
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"album": album_json,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getSong[.view]
|
||||
pub async fn get_song(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
}
|
||||
};
|
||||
|
||||
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||
}
|
||||
};
|
||||
|
||||
let child = SubsonicChild::from_track(&track);
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"song": serde_json::to_value(child).unwrap_or_default(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getGenres[.view]
|
||||
pub async fn get_genres(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// Get all tracks and extract unique genres
|
||||
let tracks = queries::tracks::list(state.db.conn(), 100_000, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut genre_counts: BTreeMap<String, (u64, u64)> = BTreeMap::new();
|
||||
for track in &tracks {
|
||||
if let Some(ref genre) = track.genre {
|
||||
let entry = genre_counts.entry(genre.clone()).or_insert((0, 0));
|
||||
entry.0 += 1; // song count
|
||||
// album count is approximated - we count unique album_ids per genre
|
||||
}
|
||||
}
|
||||
|
||||
// Also count album_ids per genre
|
||||
let mut genre_albums: BTreeMap<String, std::collections::HashSet<i32>> = BTreeMap::new();
|
||||
for track in &tracks {
|
||||
if let Some(ref genre) = track.genre
|
||||
&& let Some(album_id) = track.album_id
|
||||
{
|
||||
genre_albums
|
||||
.entry(genre.clone())
|
||||
.or_default()
|
||||
.insert(album_id);
|
||||
}
|
||||
}
|
||||
|
||||
let genre_list: Vec<serde_json::Value> = genre_counts
|
||||
.iter()
|
||||
.map(|(name, (song_count, _))| {
|
||||
let album_count = genre_albums.get(name).map(|s| s.len()).unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"songCount": song_count,
|
||||
"albumCount": album_count,
|
||||
"value": name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"genres": {
|
||||
"genre": genre_list,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getIndexes[.view] — folder-based browsing (same data as getArtists).
|
||||
pub async fn get_indexes(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_GENERIC,
|
||||
&format!("database error: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||
for artist in &artists {
|
||||
let first_char = artist
|
||||
.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('#')
|
||||
.to_uppercase()
|
||||
.next()
|
||||
.unwrap_or('#');
|
||||
let key = if first_char.is_alphabetic() {
|
||||
first_char.to_string()
|
||||
} else {
|
||||
"#".to_string()
|
||||
};
|
||||
|
||||
index_map.entry(key).or_default().push(serde_json::json!({
|
||||
"id": format!("ar-{}", artist.id),
|
||||
"name": artist.name,
|
||||
}));
|
||||
}
|
||||
|
||||
let indices: Vec<serde_json::Value> = index_map
|
||||
.into_iter()
|
||||
.map(|(name, artists)| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"artist": artists,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"indexes": {
|
||||
"ignoredArticles": "The El La Los Las Le Les",
|
||||
"index": indices,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getMusicDirectory[.view] — returns children of a directory.
|
||||
/// For artist IDs (ar-N): returns albums as children.
|
||||
/// For album IDs (al-N): returns tracks as children.
|
||||
pub async fn get_music_directory(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (prefix, db_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid id");
|
||||
}
|
||||
};
|
||||
|
||||
match prefix {
|
||||
"ar" => {
|
||||
// Artist directory → list albums
|
||||
let artist = match queries::artists::get_by_id(state.db.conn(), db_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"artist not found",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let children: Vec<serde_json::Value> = albums
|
||||
.iter()
|
||||
.map(|album| {
|
||||
serde_json::json!({
|
||||
"id": format!("al-{}", album.id),
|
||||
"parent": format!("ar-{}", artist.id),
|
||||
"isDir": true,
|
||||
"title": album.name,
|
||||
"artist": album.album_artist,
|
||||
"coverArt": format!("al-{}", album.id),
|
||||
"year": album.year,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"directory": {
|
||||
"id": id_str,
|
||||
"name": artist.name,
|
||||
"child": children,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
"al" => {
|
||||
// Album directory → list tracks
|
||||
let album = match queries::albums::get_by_id(state.db.conn(), db_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"album not found",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let children: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"directory": {
|
||||
"id": id_str,
|
||||
"name": album.name,
|
||||
"parent": album.artist_id.map(|id| format!("ar-{id}")),
|
||||
"child": children,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
"unknown" => {
|
||||
// Plain numeric ID — try artist first, then album
|
||||
if let Ok(artist) = queries::artists::get_by_id(state.db.conn(), db_id).await {
|
||||
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let children: Vec<serde_json::Value> = albums
|
||||
.iter()
|
||||
.map(|album| {
|
||||
serde_json::json!({
|
||||
"id": format!("al-{}", album.id),
|
||||
"parent": format!("ar-{}", artist.id),
|
||||
"isDir": true,
|
||||
"title": album.name,
|
||||
"artist": album.album_artist,
|
||||
"coverArt": format!("al-{}", album.id),
|
||||
"year": album.year,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"directory": {
|
||||
"id": id_str,
|
||||
"name": artist.name,
|
||||
"child": children,
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else if let Ok(album) = queries::albums::get_by_id(state.db.conn(), db_id).await {
|
||||
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let children: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"directory": {
|
||||
"id": id_str,
|
||||
"name": album.name,
|
||||
"parent": album.artist_id.map(|id| format!("ar-{id}")),
|
||||
"child": children,
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
response::error(¶ms.format, response::ERROR_NOT_FOUND, "not found")
|
||||
}
|
||||
}
|
||||
_ => response::error(¶ms.format, response::ERROR_NOT_FOUND, "unknown id type"),
|
||||
}
|
||||
}
|
||||
70
src/routes/subsonic/helpers.rs
Normal file
70
src/routes/subsonic/helpers.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use shanty_db::entities::user::Model as User;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::auth::{SubsonicAuthError, SubsonicParams, verify_auth};
|
||||
use super::response;
|
||||
|
||||
/// Extract and authenticate subsonic params, returning an error HttpResponse on failure.
|
||||
pub async fn authenticate(
|
||||
req: &HttpRequest,
|
||||
state: &web::Data<AppState>,
|
||||
) -> Result<(SubsonicParams, User), HttpResponse> {
|
||||
tracing::debug!(
|
||||
path = req.path(),
|
||||
query = req.query_string(),
|
||||
"subsonic request"
|
||||
);
|
||||
|
||||
let params = SubsonicParams::from_request(req).map_err(|e| match e {
|
||||
SubsonicAuthError::MissingParam(name) => response::error(
|
||||
"xml",
|
||||
response::ERROR_MISSING_PARAM,
|
||||
&format!("missing required parameter: {name}"),
|
||||
),
|
||||
SubsonicAuthError::AuthFailed => response::error(
|
||||
"xml",
|
||||
response::ERROR_NOT_AUTHENTICATED,
|
||||
"wrong username or password",
|
||||
),
|
||||
})?;
|
||||
|
||||
let user = verify_auth(state.db.conn(), ¶ms)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
SubsonicAuthError::AuthFailed => response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_AUTHENTICATED,
|
||||
"wrong username or password",
|
||||
),
|
||||
SubsonicAuthError::MissingParam(name) => response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
&format!("missing required parameter: {name}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok((params, user))
|
||||
}
|
||||
|
||||
/// Parse a Subsonic ID like "ar-123" into (prefix, id).
|
||||
/// Also accepts plain numbers (e.g., "123") — returns prefix "unknown".
|
||||
pub fn parse_subsonic_id(id: &str) -> Option<(&str, i32)> {
|
||||
if let Some((prefix, num_str)) = id.split_once('-') {
|
||||
let num = num_str.parse().ok()?;
|
||||
Some((prefix, num))
|
||||
} else {
|
||||
// Plain number — no prefix
|
||||
let num = id.parse().ok()?;
|
||||
Some(("unknown", num))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a query parameter by name from the request.
|
||||
pub fn get_query_param(req: &HttpRequest, name: &str) -> Option<String> {
|
||||
let qs = req.query_string();
|
||||
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||
params.into_iter().find(|(k, _)| k == name).map(|(_, v)| v)
|
||||
}
|
||||
312
src/routes/subsonic/media.rs
Normal file
312
src/routes/subsonic/media.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use tokio::process::Command;
|
||||
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||
use super::response;
|
||||
|
||||
/// GET /rest/stream[.view]
|
||||
pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
}
|
||||
};
|
||||
|
||||
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||
}
|
||||
};
|
||||
|
||||
let file_ext = std::path::Path::new(&track.file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let requested_format = get_query_param(&req, "format");
|
||||
let max_bit_rate = get_query_param(&req, "maxBitRate")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Determine if transcoding is needed:
|
||||
// - Client explicitly requests a different format
|
||||
// - File is opus/ogg (many mobile clients can't play these natively)
|
||||
// - Client requests a specific bitrate
|
||||
let needs_transcode = match requested_format.as_deref() {
|
||||
Some("raw") => false, // Explicitly asked for no transcoding
|
||||
Some(fmt) if fmt != file_ext => true, // Different format requested
|
||||
_ => {
|
||||
// Auto-transcode opus/ogg to mp3 since many clients don't support them
|
||||
matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320)
|
||||
}
|
||||
};
|
||||
|
||||
// Check file exists before doing anything
|
||||
if !std::path::Path::new(&track.file_path).exists() {
|
||||
tracing::error!(path = %track.file_path, "track file not found on disk");
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
&format!("file not found: {}", track.file_path),
|
||||
);
|
||||
}
|
||||
|
||||
if needs_transcode {
|
||||
let target_format = requested_format
|
||||
.as_deref()
|
||||
.filter(|f| *f != "raw")
|
||||
.unwrap_or("mp3");
|
||||
let bitrate = if max_bit_rate > 0 {
|
||||
max_bit_rate
|
||||
} else {
|
||||
192 // Default transcoding bitrate
|
||||
};
|
||||
|
||||
let content_type = match target_format {
|
||||
"mp3" => "audio/mpeg",
|
||||
"opus" => "audio/ogg",
|
||||
"ogg" => "audio/ogg",
|
||||
"aac" | "m4a" => "audio/mp4",
|
||||
"flac" => "audio/flac",
|
||||
_ => "audio/mpeg",
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
track_id = track_id,
|
||||
from = %file_ext,
|
||||
to = target_format,
|
||||
bitrate = bitrate,
|
||||
"transcoding stream"
|
||||
);
|
||||
|
||||
match Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
&track.file_path,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-b:a",
|
||||
&format!("{bitrate}k"),
|
||||
"-v",
|
||||
"0",
|
||||
"-f",
|
||||
target_format,
|
||||
"-",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if output.status.success() && !output.stdout.is_empty() {
|
||||
tracing::debug!(
|
||||
track_id = track_id,
|
||||
bytes = output.stdout.len(),
|
||||
"transcoding complete"
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
.content_type(content_type)
|
||||
.body(output.stdout)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!(
|
||||
status = ?output.status,
|
||||
stderr = %stderr,
|
||||
path = %track.file_path,
|
||||
"ffmpeg transcoding failed"
|
||||
);
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(_) => response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"transcoding failed",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to start ffmpeg");
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(_) => {
|
||||
response::error(¶ms.format, response::ERROR_NOT_FOUND, "file not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Serve the file directly with Range request support
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(e) => {
|
||||
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming");
|
||||
response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"file not found on disk",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /rest/download[.view]
|
||||
pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
}
|
||||
};
|
||||
|
||||
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||
}
|
||||
};
|
||||
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => {
|
||||
let file = file.set_content_disposition(actix_web::http::header::ContentDisposition {
|
||||
disposition: actix_web::http::header::DispositionType::Attachment,
|
||||
parameters: vec![actix_web::http::header::DispositionParam::Filename(
|
||||
std::path::Path::new(&track.file_path)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("track")
|
||||
.to_string(),
|
||||
)],
|
||||
});
|
||||
file.into_response(&req)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
|
||||
response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"file not found on disk",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /rest/getCoverArt[.view]
|
||||
pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Cover art IDs can be album IDs (al-N) or artist IDs (ar-N)
|
||||
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid cover art id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match prefix {
|
||||
"al" => {
|
||||
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"album not found",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref cover_art_path) = album.cover_art_path {
|
||||
// If it's a URL, redirect to it
|
||||
if cover_art_path.starts_with("http://") || cover_art_path.starts_with("https://") {
|
||||
return HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", cover_art_path.as_str()))
|
||||
.finish();
|
||||
}
|
||||
|
||||
// Otherwise try to serve as a local file
|
||||
match NamedFile::open_async(cover_art_path).await {
|
||||
Ok(file) => return file.into_response(&req),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %cover_art_path, error = %e, "cover art file not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If album has a MusicBrainz ID, redirect to Cover Art Archive
|
||||
if let Some(ref mbid) = album.musicbrainz_id {
|
||||
let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
|
||||
return HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", url.as_str()))
|
||||
.finish();
|
||||
}
|
||||
|
||||
// No cover art available
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
_ => {
|
||||
// For other types, no cover art
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/routes/subsonic/mod.rs
Normal file
86
src/routes/subsonic/mod.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
mod annotation;
|
||||
mod auth;
|
||||
mod browsing;
|
||||
mod helpers;
|
||||
mod media;
|
||||
mod playlists;
|
||||
mod response;
|
||||
mod search;
|
||||
mod system;
|
||||
mod user;
|
||||
|
||||
use actix_web::web;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/rest")
|
||||
// System
|
||||
.route("/ping", web::get().to(system::ping))
|
||||
.route("/ping.view", web::get().to(system::ping))
|
||||
.route("/getLicense", web::get().to(system::get_license))
|
||||
.route("/getLicense.view", web::get().to(system::get_license))
|
||||
// Browsing
|
||||
.route(
|
||||
"/getMusicFolders",
|
||||
web::get().to(browsing::get_music_folders),
|
||||
)
|
||||
.route(
|
||||
"/getMusicFolders.view",
|
||||
web::get().to(browsing::get_music_folders),
|
||||
)
|
||||
.route("/getIndexes", web::get().to(browsing::get_indexes))
|
||||
.route("/getIndexes.view", web::get().to(browsing::get_indexes))
|
||||
.route(
|
||||
"/getMusicDirectory",
|
||||
web::get().to(browsing::get_music_directory),
|
||||
)
|
||||
.route(
|
||||
"/getMusicDirectory.view",
|
||||
web::get().to(browsing::get_music_directory),
|
||||
)
|
||||
.route("/getArtists", web::get().to(browsing::get_artists))
|
||||
.route("/getArtists.view", web::get().to(browsing::get_artists))
|
||||
.route("/getArtist", web::get().to(browsing::get_artist))
|
||||
.route("/getArtist.view", web::get().to(browsing::get_artist))
|
||||
.route("/getAlbum", web::get().to(browsing::get_album))
|
||||
.route("/getAlbum.view", web::get().to(browsing::get_album))
|
||||
.route("/getSong", web::get().to(browsing::get_song))
|
||||
.route("/getSong.view", web::get().to(browsing::get_song))
|
||||
.route("/getGenres", web::get().to(browsing::get_genres))
|
||||
.route("/getGenres.view", web::get().to(browsing::get_genres))
|
||||
// Search
|
||||
.route("/search3", web::get().to(search::search3))
|
||||
.route("/search3.view", web::get().to(search::search3))
|
||||
// Media
|
||||
.route("/stream", web::get().to(media::stream))
|
||||
.route("/stream.view", web::get().to(media::stream))
|
||||
.route("/download", web::get().to(media::download))
|
||||
.route("/download.view", web::get().to(media::download))
|
||||
.route("/getCoverArt", web::get().to(media::get_cover_art))
|
||||
.route("/getCoverArt.view", web::get().to(media::get_cover_art))
|
||||
// Playlists
|
||||
.route("/getPlaylists", web::get().to(playlists::get_playlists))
|
||||
.route(
|
||||
"/getPlaylists.view",
|
||||
web::get().to(playlists::get_playlists),
|
||||
)
|
||||
.route("/getPlaylist", web::get().to(playlists::get_playlist))
|
||||
.route("/getPlaylist.view", web::get().to(playlists::get_playlist))
|
||||
.route("/createPlaylist", web::get().to(playlists::create_playlist))
|
||||
.route(
|
||||
"/createPlaylist.view",
|
||||
web::get().to(playlists::create_playlist),
|
||||
)
|
||||
.route("/deletePlaylist", web::get().to(playlists::delete_playlist))
|
||||
.route(
|
||||
"/deletePlaylist.view",
|
||||
web::get().to(playlists::delete_playlist),
|
||||
)
|
||||
// Annotation
|
||||
.route("/scrobble", web::get().to(annotation::scrobble))
|
||||
.route("/scrobble.view", web::get().to(annotation::scrobble))
|
||||
// User
|
||||
.route("/getUser", web::get().to(user::get_user))
|
||||
.route("/getUser.view", web::get().to(user::get_user)),
|
||||
);
|
||||
}
|
||||
250
src/routes/subsonic/playlists.rs
Normal file
250
src/routes/subsonic/playlists.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||
use super::response::{self, SubsonicChild};
|
||||
|
||||
/// GET /rest/getPlaylists[.view]
|
||||
pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let playlists = queries::playlists::list(state.db.conn(), Some(user.id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut playlist_list: Vec<serde_json::Value> = Vec::new();
|
||||
for pl in &playlists {
|
||||
let track_count = queries::playlists::get_track_count(state.db.conn(), pl.id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// Calculate total duration
|
||||
let tracks = queries::playlists::get_tracks(state.db.conn(), pl.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let duration: i32 = tracks
|
||||
.iter()
|
||||
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||
.sum();
|
||||
|
||||
let mut pl_json = serde_json::json!({
|
||||
"id": format!("pl-{}", pl.id),
|
||||
"name": pl.name,
|
||||
"owner": user.username,
|
||||
"public": false,
|
||||
"songCount": track_count,
|
||||
"duration": duration,
|
||||
"created": pl.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"changed": pl.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
});
|
||||
if let Some(ref desc) = pl.description {
|
||||
pl_json["comment"] = serde_json::json!(desc);
|
||||
}
|
||||
playlist_list.push(pl_json);
|
||||
}
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"playlists": {
|
||||
"playlist": playlist_list,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/getPlaylist[.view]
|
||||
pub async fn get_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid playlist id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"playlist not found",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration: i32 = tracks
|
||||
.iter()
|
||||
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||
.sum();
|
||||
|
||||
let entry_list: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
let mut pl_json = serde_json::json!({
|
||||
"id": format!("pl-{}", playlist.id),
|
||||
"name": playlist.name,
|
||||
"owner": user.username,
|
||||
"public": false,
|
||||
"songCount": tracks.len(),
|
||||
"duration": duration,
|
||||
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"entry": entry_list,
|
||||
});
|
||||
if let Some(ref desc) = playlist.description {
|
||||
pl_json["comment"] = serde_json::json!(desc);
|
||||
}
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"playlist": pl_json,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /rest/createPlaylist[.view]
|
||||
pub async fn create_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let name = match get_query_param(&req, "name") {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: name",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Collect songId params (can be repeated)
|
||||
let qs = req.query_string();
|
||||
let query_params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||
let track_ids: Vec<i32> = query_params
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "songId")
|
||||
.filter_map(|(_, v)| parse_subsonic_id(v).map(|(_, id)| id))
|
||||
.collect();
|
||||
|
||||
match queries::playlists::create(state.db.conn(), &name, None, Some(user.id), &track_ids).await
|
||||
{
|
||||
Ok(playlist) => {
|
||||
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let duration: i32 = tracks
|
||||
.iter()
|
||||
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||
.sum();
|
||||
let entry_list: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.map(|track| {
|
||||
serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pl_json = serde_json::json!({
|
||||
"id": format!("pl-{}", playlist.id),
|
||||
"name": playlist.name,
|
||||
"owner": user.username,
|
||||
"public": false,
|
||||
"songCount": tracks.len(),
|
||||
"duration": duration,
|
||||
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"entry": entry_list,
|
||||
});
|
||||
if let Some(ref desc) = playlist.description {
|
||||
pl_json["comment"] = serde_json::json!(desc);
|
||||
}
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"playlist": pl_json,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(e) => response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_GENERIC,
|
||||
&format!("failed to create playlist: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /rest/deletePlaylist[.view]
|
||||
pub async fn delete_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid playlist id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match queries::playlists::delete(state.db.conn(), playlist_id).await {
|
||||
Ok(()) => response::ok(¶ms.format, serde_json::json!({})),
|
||||
Err(e) => response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_GENERIC,
|
||||
&format!("failed to delete playlist: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
249
src/routes/subsonic/response.rs
Normal file
249
src/routes/subsonic/response.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use actix_web::HttpResponse;
|
||||
use serde::Serialize;
|
||||
|
||||
const SUBSONIC_VERSION: &str = "1.16.1";
|
||||
const XMLNS: &str = "http://subsonic.org/restapi";
|
||||
|
||||
/// Build a successful Subsonic response in the requested format.
|
||||
pub fn ok(format: &str, body: serde_json::Value) -> HttpResponse {
|
||||
format_response(format, "ok", body, None)
|
||||
}
|
||||
|
||||
/// Build a Subsonic error response.
|
||||
pub fn error(format: &str, code: u32, message: &str) -> HttpResponse {
|
||||
let err = serde_json::json!({
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
});
|
||||
format_response(format, "failed", err, None)
|
||||
}
|
||||
|
||||
/// Subsonic error codes.
|
||||
pub const ERROR_GENERIC: u32 = 0;
|
||||
pub const ERROR_MISSING_PARAM: u32 = 10;
|
||||
pub const ERROR_NOT_AUTHENTICATED: u32 = 40;
|
||||
#[allow(dead_code)]
|
||||
pub const ERROR_NOT_AUTHORIZED: u32 = 50;
|
||||
pub const ERROR_NOT_FOUND: u32 = 70;
|
||||
|
||||
fn format_response(
|
||||
format: &str,
|
||||
status: &str,
|
||||
body: serde_json::Value,
|
||||
_type_attr: Option<&str>,
|
||||
) -> HttpResponse {
|
||||
match format {
|
||||
"json" => format_json(status, body),
|
||||
_ => format_xml(status, body),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
|
||||
let mut response = serde_json::json!({
|
||||
"status": status,
|
||||
"version": SUBSONIC_VERSION,
|
||||
"type": "shanty",
|
||||
"serverVersion": "0.1.0",
|
||||
"openSubsonic": true,
|
||||
});
|
||||
|
||||
// Merge body into response
|
||||
if let serde_json::Value::Object(map) = body
|
||||
&& let serde_json::Value::Object(ref mut resp_map) = response
|
||||
{
|
||||
for (k, v) in map {
|
||||
resp_map.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
let wrapper = serde_json::json!({
|
||||
"subsonic-response": response,
|
||||
});
|
||||
|
||||
HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.json(wrapper)
|
||||
}
|
||||
|
||||
fn format_xml(status: &str, body: serde_json::Value) -> HttpResponse {
|
||||
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
xml.push_str(&format!(
|
||||
"<subsonic-response xmlns=\"{XMLNS}\" status=\"{status}\" version=\"{SUBSONIC_VERSION}\" type=\"shanty\" serverVersion=\"0.1.0\" openSubsonic=\"true\">"
|
||||
));
|
||||
|
||||
if let serde_json::Value::Object(map) = &body {
|
||||
for (key, value) in map {
|
||||
json_to_xml(&mut xml, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
xml.push_str("</subsonic-response>");
|
||||
|
||||
HttpResponse::Ok()
|
||||
.content_type("application/xml; charset=UTF-8")
|
||||
.body(xml)
|
||||
}
|
||||
|
||||
/// Convert a JSON value into XML elements. The Subsonic XML format uses:
|
||||
/// - Object keys become element names
|
||||
/// - Primitive values in objects become attributes
|
||||
/// - Arrays become repeated elements
|
||||
/// - Nested objects become child elements
|
||||
fn json_to_xml(xml: &mut String, tag: &str, value: &serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
for item in arr {
|
||||
json_to_xml(xml, tag, item);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
xml.push_str(&format!("<{tag}"));
|
||||
|
||||
let mut children = Vec::new();
|
||||
for (k, v) in map {
|
||||
match v {
|
||||
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
|
||||
children.push((k, v));
|
||||
}
|
||||
_ => {
|
||||
let val_str = match v {
|
||||
serde_json::Value::String(s) => xml_escape(s),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
_ => v.to_string(),
|
||||
};
|
||||
xml.push_str(&format!(" {k}=\"{val_str}\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if children.is_empty() {
|
||||
xml.push_str("/>");
|
||||
} else {
|
||||
xml.push('>');
|
||||
for (k, v) in children {
|
||||
json_to_xml(xml, k, v);
|
||||
}
|
||||
xml.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
xml.push_str(&format!("<{tag}>{}</{tag}>", xml_escape(s)));
|
||||
}
|
||||
serde_json::Value::Number(n) => {
|
||||
xml.push_str(&format!("<{tag}>{n}</{tag}>"));
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
xml.push_str(&format!("<{tag}>{b}</{tag}>"));
|
||||
}
|
||||
serde_json::Value::Null => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Helper to build a "child" (track) JSON for Subsonic responses.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubsonicChild {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
pub is_dir: bool,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artist: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub track: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genre: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_art: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suffix: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bit_rate: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disc_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artist_id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
impl SubsonicChild {
|
||||
pub fn from_track(track: &shanty_db::entities::track::Model) -> Self {
|
||||
let suffix = std::path::Path::new(&track.file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let content_type = suffix.as_deref().map(|s| {
|
||||
match s {
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "opus" => "audio/ogg",
|
||||
"m4a" | "aac" => "audio/mp4",
|
||||
"wav" => "audio/wav",
|
||||
"wma" => "audio/x-ms-wma",
|
||||
_ => "audio/mpeg",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let path_display = format!(
|
||||
"{}/{}",
|
||||
track.artist.as_deref().unwrap_or("Unknown Artist"),
|
||||
std::path::Path::new(&track.file_path)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("unknown")
|
||||
);
|
||||
|
||||
SubsonicChild {
|
||||
id: format!("tr-{}", track.id),
|
||||
parent: track.album_id.map(|id| format!("al-{id}")),
|
||||
is_dir: false,
|
||||
title: track.title.clone().unwrap_or_else(|| "Unknown".to_string()),
|
||||
album: track.album.clone(),
|
||||
artist: track.artist.clone(),
|
||||
track: track.track_number,
|
||||
year: track.year,
|
||||
genre: track.genre.clone(),
|
||||
cover_art: track.album_id.map(|id| format!("al-{id}")),
|
||||
size: Some(track.file_size),
|
||||
content_type,
|
||||
suffix,
|
||||
duration: track.duration.map(|d| d as i32),
|
||||
bit_rate: track.bitrate,
|
||||
path: Some(path_display),
|
||||
disc_number: track.disc_number,
|
||||
album_id: track.album_id.map(|id| format!("al-{id}")),
|
||||
artist_id: track.artist_id.map(|id| format!("ar-{id}")),
|
||||
media_type: Some("music".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/routes/subsonic/search.rs
Normal file
124
src/routes/subsonic/search.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use shanty_db::queries;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param};
|
||||
use super::response::{self, SubsonicChild};
|
||||
|
||||
/// GET /rest/search3[.view]
|
||||
pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let query = match get_query_param(&req, "query") {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: query",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let artist_count: u64 = get_query_param(&req, "artistCount")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let album_count: u64 = get_query_param(&req, "albumCount")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let song_count: u64 = get_query_param(&req, "songCount")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
|
||||
// Search tracks (which gives us artists and albums too)
|
||||
let tracks = queries::tracks::search(state.db.conn(), &query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect unique artists from tracks
|
||||
let mut seen_artists = std::collections::HashSet::new();
|
||||
let mut artist_results: Vec<serde_json::Value> = Vec::new();
|
||||
for track in &tracks {
|
||||
if let Some(artist_id) = track.artist_id
|
||||
&& seen_artists.insert(artist_id)
|
||||
&& artist_results.len() < artist_count as usize
|
||||
&& let Ok(artist) = queries::artists::get_by_id(state.db.conn(), artist_id).await
|
||||
{
|
||||
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist_id)
|
||||
.await
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
artist_results.push(serde_json::json!({
|
||||
"id": format!("ar-{}", artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": album_ct,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Also search artists by name directly
|
||||
let all_artists = queries::artists::list(state.db.conn(), 10000, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let query_lower = query.to_lowercase();
|
||||
for artist in &all_artists {
|
||||
if artist.name.to_lowercase().contains(&query_lower)
|
||||
&& seen_artists.insert(artist.id)
|
||||
&& artist_results.len() < artist_count as usize
|
||||
{
|
||||
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist.id)
|
||||
.await
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
artist_results.push(serde_json::json!({
|
||||
"id": format!("ar-{}", artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": album_ct,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique albums from tracks
|
||||
let mut seen_albums = std::collections::HashSet::new();
|
||||
let mut album_results: Vec<serde_json::Value> = Vec::new();
|
||||
for track in &tracks {
|
||||
if let Some(aid) = track.album_id
|
||||
&& seen_albums.insert(aid)
|
||||
&& album_results.len() < album_count as usize
|
||||
&& let Ok(album) = queries::albums::get_by_id(state.db.conn(), aid).await
|
||||
{
|
||||
album_results.push(serde_json::json!({
|
||||
"id": format!("al-{}", album.id),
|
||||
"name": album.name,
|
||||
"artist": album.album_artist,
|
||||
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
|
||||
"coverArt": format!("al-{}", album.id),
|
||||
"year": album.year,
|
||||
"genre": album.genre,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Song results
|
||||
let song_results: Vec<serde_json::Value> = tracks
|
||||
.iter()
|
||||
.take(song_count as usize)
|
||||
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"searchResult3": {
|
||||
"artist": artist_results,
|
||||
"album": album_results,
|
||||
"song": song_results,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
35
src/routes/subsonic/system.rs
Normal file
35
src/routes/subsonic/system.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::authenticate;
|
||||
use super::response;
|
||||
|
||||
/// GET /rest/ping[.view]
|
||||
pub async fn ping(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
response::ok(¶ms.format, serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// GET /rest/getLicense[.view]
|
||||
pub async fn get_license(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"license": {
|
||||
"valid": true,
|
||||
"email": "shanty@localhost",
|
||||
"licenseExpires": "2099-12-31T23:59:59",
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
42
src/routes/subsonic/user.rs
Normal file
42
src/routes/subsonic/user.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
use shanty_db::entities::user::UserRole;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::authenticate;
|
||||
use super::response;
|
||||
|
||||
/// GET /rest/getUser[.view]
|
||||
pub async fn get_user(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, user) = match authenticate(&req, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let is_admin = user.role == UserRole::Admin;
|
||||
|
||||
response::ok(
|
||||
¶ms.format,
|
||||
serde_json::json!({
|
||||
"user": {
|
||||
"username": user.username,
|
||||
"email": "",
|
||||
"scrobblingEnabled": false,
|
||||
"adminRole": is_admin,
|
||||
"settingsRole": is_admin,
|
||||
"downloadRole": true,
|
||||
"uploadRole": false,
|
||||
"playlistRole": true,
|
||||
"coverArtRole": false,
|
||||
"commentRole": false,
|
||||
"podcastRole": false,
|
||||
"streamRole": true,
|
||||
"jukeboxRole": false,
|
||||
"shareRole": false,
|
||||
"videoConversionRole": false,
|
||||
"folder": [1],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user