Minimal subsonic functionality

This commit is contained in:
Connor Johnstone
2026-03-20 20:04:35 -04:00
parent abe321a317
commit 621355e352
19 changed files with 2107 additions and 22 deletions

View 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(
&params.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(
&params.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(
&params.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(
&params.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(
&params.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(
&params.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(
&params.format,
serde_json::json!({
"playlist": pl_json,
}),
)
}
Err(e) => response::error(
&params.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(
&params.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(
&params.format,
response::ERROR_NOT_FOUND,
"invalid playlist id",
);
}
};
match queries::playlists::delete(state.db.conn(), playlist_id).await {
Ok(()) => response::ok(&params.format, serde_json::json!({})),
Err(e) => response::error(
&params.format,
response::ERROR_GENERIC,
&format!("failed to delete playlist: {e}"),
),
}
}