add a bunch of qol features

This commit is contained in:
2026-06-24 20:02:07 +02:00
parent 1b430aab74
commit 5e14a9e308
11 changed files with 1881 additions and 246 deletions
+407 -45
View File
@@ -4,7 +4,8 @@ use log::{debug, error, info, warn};
use serde::Serialize;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use crate::state::DaemonState;
use crate::audio::AudioCmd;
use crate::state::{init_async, init_batch, DaemonState, RepeatMode};
#[derive(Serialize)]
struct ApiError {
@@ -60,8 +61,12 @@ pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
}
}
/// Owns the request so we can call `request.respond()` at the end. Reads the
/// body (for POST/PUT/DELETE), routes, and replies with JSON.
fn read_body(request: &mut Request) -> Result<String, std::io::Error> {
let mut s = String::new();
let _ = request.as_reader().read_to_string(&mut s)?;
Ok(s)
}
fn handle(
mut request: Request,
state: Arc<Mutex<DaemonState>>,
@@ -71,40 +76,67 @@ fn handle(
let path = url.split('?').next().unwrap_or(url);
let route = route_for(method, path);
// /help short-circuit: no body, no state.
if matches!(route, Route::Help) {
let resp = json_response(200, &serde_json::json!({
"endpoints": [
"GET /help",
"GET /songs",
"GET /search?q=... or POST /search {query}",
"GET /songs?q=",
"GET /search?q=... (best match) or POST /search {query}",
"GET /search/all?q=... (every match above threshold)",
"GET /queue",
"GET /now-playing",
"GET /downloads",
"GET /history?limit=N",
"GET /stats",
"GET /repeat",
"GET /volume",
"POST /play {query}",
"POST /add {query}",
"POST /del {query}",
"POST /init {song}",
"POST /init {song} (async, returns job id, status 202)",
"POST /init/batch {songs:[...]}",
"POST /skip",
"POST /pause",
"POST /resume",
"POST /seek {secs}",
"POST /volume {vol}",
"POST /repeat {mode}",
"POST /queue/shuffle",
"POST /queue/clear",
"GET /playlists",
"POST /playlists {name}",
"GET /playlists/{name}",
"PATCH /playlists/{name} {name}",
"DELETE /playlists/{name}",
"POST /playlists/{name}/add {query}",
"POST /playlists/{name}/play",
"DELETE /playlists/{name}",
"DELETE /playlists/{name}/songs/{id}",
"POST /playlists/{name}/reorder {from, to}",
"POST /playlists/{name}/duplicate {name}",
]
}));
return Ok(request.respond(resp)?);
}
let mut body_text = String::new();
if matches!(method, Method::Post | Method::Delete | Method::Put) {
let _ = request.as_reader().read_to_string(&mut body_text)?;
if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) {
match read_body(&mut request) {
Ok(s) => body_text = s,
Err(e) => {
warn!("Failed to read body: {e}");
let resp = error_response("could not read request body");
return Ok(request.respond(resp)?);
}
}
}
debug!("Body: {}", body_text);
let query_params = parse_query(url);
// Clone the Arc so async routes can spawn workers that retain their
// own handle after we lock the original Arc into a MutexGuard.
let arc_for_async = Arc::clone(&state);
let mut state = match state.lock() {
Ok(s) => s,
Err(p) => {
@@ -115,11 +147,14 @@ fn handle(
};
let response = match route {
Route::Help => unreachable!("handled above"),
Route::Help => unreachable!(),
Route::Songs => json_response(200, &serde_json::json!({
"songs": state.all_songs(),
})),
Route::Songs => {
let q = query_params.get("q").map(|s| s.as_str());
json_response(200, &serde_json::json!({
"songs": state.list_songs_filtered(q),
}))
}
Route::Search => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.search(&q) {
@@ -131,16 +166,37 @@ fn handle(
Err(e) => error_response(e),
},
Route::SearchAll => match query_or_body(&query_params, &body_text) {
Ok(q) => json_response(200, &serde_json::json!({
"matches": state.search_all(&q),
})),
Err(e) => error_response(e),
},
Route::Queue => json_response(200, &serde_json::json!({
"queue": state.queue,
"current": state.current,
"repeat": state.repeat_mode.as_str(),
})),
Route::NowPlaying => json_response(200, &serde_json::json!({
"current": state.current,
"queue_len": state.queue.len(),
Route::NowPlaying => json_response_now_playing(&state),
Route::Downloads => json_response(200, &serde_json::json!({
"downloads": state.list_downloads(),
})),
Route::History => {
let limit: usize = query_params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(50);
json_response(200, &serde_json::json!({
"history": state.list_history(limit),
}))
}
Route::Stats => json_response(200, &state.stats()),
Route::Play => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.play(&q) {
Ok(song) => json_response(200, &song),
@@ -152,7 +208,10 @@ fn handle(
Route::Add => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.add(&q) {
Ok(song) => json_response(200, &song),
Err(e) => error_response(e),
Err(e) => json_response(
if e.contains("queue full") { 413 } else { 400 },
&ApiError { error: e },
),
},
Err(e) => error_response(e),
},
@@ -165,17 +224,43 @@ fn handle(
Err(e) => error_response(e),
},
Route::Init => match name_from_body(&body_text) {
Ok(song) => match state.init(song) {
Ok(s) => json_response(200, &s),
Err(e) => error_response(e),
},
Route::InitAsync => match name_from_body(&body_text) {
Ok(song) => {
let id = init_async(&arc_for_async, song);
json_response(202, &serde_json::json!({
"job_id": id,
"status": "queued",
}))
}
Err(e) => error_response(e),
},
Route::Skip => match state.skip() {
Ok(Some(s)) => json_response(200, &s),
Ok(None) => json_response(200, &serde_json::json!({"skipped": null})),
Route::InitBatch => match songs_from_body(&body_text) {
Ok(songs) => {
let ids = init_batch(&arc_for_async, songs);
json_response(202, &serde_json::json!({
"job_ids": ids,
}))
}
Err(e) => error_response(e),
},
Route::Skip => {
state.audio_handle.send(AudioCmd::Stop);
// Always clear current — a skip is an explicit decision to
// abandon the playing song, so we don't want the daemon tick to
// later treat it as a natural end-of-track and write a bogus
// `history` row.
state.current = None;
match state.skip() {
Ok(Some(s)) => json_response(200, &s),
Ok(None) => json_response(200, &serde_json::json!({"skipped": null})),
Err(e) => error_response(e),
}
}
Route::QueueShuffle => match state.shuffle_queue() {
Ok(()) => json_response(200, &serde_json::json!({"shuffled": true, "len": state.queue.len()})),
Err(e) => error_response(e),
},
@@ -184,6 +269,44 @@ fn handle(
Err(e) => error_response(e),
},
Route::Audio(method) => handle_audio_route(&state, method, &body_text),
Route::RepeatGet => json_response(200, &serde_json::json!({
"mode": state.repeat_mode.as_str(),
})),
Route::RepeatSet => match mode_from_body_or_default(&body_text) {
Ok(mode) => {
state.repeat_mode = mode;
if matches!(mode, RepeatMode::All) && state.cycle_snapshot.is_empty() {
state.cycle_snapshot = state.queue.clone();
}
info!("Repeat mode set to {}", mode.as_str());
json_response(200, &serde_json::json!({"mode": mode.as_str()}))
}
Err(e) => error_response(e),
},
Route::VolumeGet => {
let snap = state.audio_handle.snapshot();
json_response(200, &serde_json::json!({
"volume": snap.volume,
"playing": snap.playing,
"paused": snap.paused,
}))
}
Route::VolumeSet => match vol_from_body(&body_text) {
Ok(v) => {
state.audio_handle.send(AudioCmd::SetVolume(v));
json_response(200, &serde_json::json!({
"volume": state.audio_handle.snapshot().volume,
"ok": true,
}))
}
Err(e) => error_response(e),
},
Route::PlaylistsList => json_response(200, &state.list_playlists()),
Route::PlaylistCreate => match name_from_body(&body_text) {
@@ -199,6 +322,19 @@ fn handle(
Err(e) => error_response(e),
},
Route::PlaylistRename(name) => match new_name_from_body(&body_text) {
Ok(new_name) => match state.rename_playlist(&name, &new_name) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::PlaylistDelete(name) => match state.delete_playlist(&name) {
Ok(()) => json_response(200, &serde_json::json!({"deleted": name})),
Err(e) => error_response(e),
},
Route::PlaylistAdd(name) => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.add_to_playlist(&name, &q) {
Ok((pl, song)) => json_response(200, &serde_json::json!({
@@ -211,12 +347,31 @@ fn handle(
},
Route::PlaylistPlay(name) => match state.play_playlist(&name) {
Ok(pl) => {
state.cycle_snapshot = pl.songs.clone();
json_response(200, &pl)
}
Err(e) => error_response(e),
},
Route::PlaylistRemoveSong(name, id) => match state.remove_from_playlist(&name, id) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Route::PlaylistDelete(name) => match state.delete_playlist(&name) {
Ok(()) => json_response(200, &serde_json::json!({"deleted": name})),
Route::PlaylistReorder(name) => match reorder_from_body(&body_text) {
Ok((from, to)) => match state.reorder_playlist(&name, from, to) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::PlaylistDuplicate(name) => match new_name_from_body(&body_text) {
Ok(new_name) => match state.duplicate_playlist(&name, &new_name) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
@@ -228,24 +383,90 @@ fn handle(
Ok(request.respond(response)?)
}
fn json_response_now_playing(state: &DaemonState) -> Response<std::io::Cursor<Vec<u8>>> {
let snap = state.audio_handle.snapshot();
let current_duration = state.current.as_ref().and_then(|c| c.duration_secs);
let duration = snap.duration_secs.or(current_duration);
json_response(
200,
&serde_json::json!({
"current": state.current,
"queue_len": state.queue.len(),
"elapsed_secs": snap.elapsed_secs(),
"duration_secs": duration,
"paused": snap.paused,
"playing": snap.playing,
"volume": snap.volume,
"repeat": state.repeat_mode.as_str(),
}),
)
}
#[derive(Debug)]
enum AudioRoute {
Pause,
Resume,
Seek,
}
fn handle_audio_route(
state: &DaemonState,
route: AudioRoute,
body: &str,
) -> Response<std::io::Cursor<Vec<u8>>> {
match route {
AudioRoute::Pause => {
state.audio_handle.send(AudioCmd::Pause);
json_response(200, &serde_json::json!({"ok": true, "label": "pause"}))
}
AudioRoute::Resume => {
state.audio_handle.send(AudioCmd::Resume);
json_response(200, &serde_json::json!({"ok": true, "label": "resume"}))
}
AudioRoute::Seek => match secs_from_body(body) {
Ok(s) => {
state.audio_handle.send(AudioCmd::Seek(s));
json_response(200, &serde_json::json!({"ok": true, "label": "seek", "secs": s}))
}
Err(e) => error_response(e),
},
}
}
#[derive(Debug)]
enum Route {
Help,
Songs,
Search,
SearchAll,
Queue,
NowPlaying,
Downloads,
History,
Stats,
Play,
Add,
Del,
Init,
InitAsync,
InitBatch,
Skip,
QueueShuffle,
QueueClear,
Audio(AudioRoute),
RepeatGet,
RepeatSet,
VolumeGet,
VolumeSet,
PlaylistsList,
PlaylistCreate,
PlaylistGet(String),
PlaylistRename(String),
PlaylistDelete(String),
PlaylistAdd(String),
PlaylistPlay(String),
PlaylistDelete(String),
PlaylistRemoveSong(String, i64),
PlaylistReorder(String),
PlaylistDuplicate(String),
NotFound,
}
@@ -255,44 +476,93 @@ fn route_for(method: &Method, path: &str) -> Route {
(Method::Get, "") | (Method::Get, "/help") => Route::Help,
(Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/search/all") => Route::SearchAll,
(Method::Get, "/queue") => Route::Queue,
(Method::Get, "/now-playing") => Route::NowPlaying,
(Method::Get, "/downloads") => Route::Downloads,
(Method::Get, "/history") => Route::History,
(Method::Get, "/stats") => Route::Stats,
(Method::Post, "/play") => Route::Play,
(Method::Post, "/add") => Route::Add,
(Method::Post, "/del") => Route::Del,
(Method::Post, "/init") => Route::Init,
(Method::Post, "/init") => Route::InitAsync,
(Method::Post, "/init/batch") => Route::InitBatch,
(Method::Post, "/skip") => Route::Skip,
(Method::Post, "/queue/shuffle") => Route::QueueShuffle,
(Method::Post, "/queue/clear") => Route::QueueClear,
(Method::Post, "/pause") => Route::Audio(AudioRoute::Pause),
(Method::Post, "/resume") => Route::Audio(AudioRoute::Resume),
(Method::Post, "/seek") => Route::Audio(AudioRoute::Seek),
(Method::Get, "/repeat") => Route::RepeatGet,
(Method::Post, "/repeat") => Route::RepeatSet,
(Method::Get, "/volume") => Route::VolumeGet,
(Method::Post, "/volume") => Route::VolumeSet,
(Method::Get, "/playlists") => Route::PlaylistsList,
(Method::Post, "/playlists") => Route::PlaylistCreate,
(Method::Get, path) if path.starts_with("/playlists/") => {
let rest = &path["/playlists/".len()..];
let (name, suffix) = split_playlist_route(rest);
playlist_get_route(path)
}
(Method::Patch, path) if path.starts_with("/playlists/") => {
let rest = strip_prefix("/playlists/", path);
let (name, suffix) = split_playlist_suffix(rest);
match suffix {
"/add" => Route::PlaylistAdd(name),
"/play" => Route::PlaylistPlay(name),
"" => Route::PlaylistGet(name),
"" => Route::PlaylistRename(name),
_ => Route::NotFound,
}
}
(Method::Delete, path) if path.starts_with("/playlists/") => {
let name = path["/playlists/".len()..].to_string();
Route::PlaylistDelete(name)
let rest = strip_prefix("/playlists/", path);
if let Some((name, id)) = split_song_path(rest) {
return Route::PlaylistRemoveSong(name, id);
}
Route::PlaylistDelete(rest.to_string())
}
(Method::Post, path) if path.starts_with("/playlists/") => {
let rest = strip_prefix("/playlists/", path);
let (name, suffix) = split_playlist_suffix(rest);
match suffix {
"/add" => Route::PlaylistAdd(name),
"/play" => Route::PlaylistPlay(name),
"/reorder" => Route::PlaylistReorder(name),
"/duplicate" => Route::PlaylistDuplicate(name),
_ => Route::NotFound,
}
}
_ => Route::NotFound,
}
}
fn split_playlist_route(rest: &str) -> (String, &'static str) {
if let Some(name) = rest.strip_suffix("/add") {
return (name.to_string(), "/add");
fn playlist_get_route(path: &str) -> Route {
let rest = strip_prefix("/playlists/", path);
let (name, suffix) = split_playlist_suffix(rest);
match suffix {
"/add" | "/play" | "/reorder" | "/duplicate" => Route::NotFound,
"" => Route::PlaylistGet(name),
_ => Route::NotFound,
}
if let Some(name) = rest.strip_suffix("/play") {
return (name.to_string(), "/play");
}
fn strip_prefix<'a>(prefix: &str, s: &'a str) -> &'a str {
&s[prefix.len()..]
}
fn split_playlist_suffix(rest: &str) -> (String, &'static str) {
for suf in ["/duplicate", "/reorder", "/add", "/play"] {
if let Some(name) = rest.strip_suffix(suf) {
return (name.to_string(), suf);
}
}
(rest.to_string(), "")
}
fn split_song_path(rest: &str) -> Option<(String, i64)> {
let idx = rest.find("/songs/")?;
let name = &rest[..idx];
let tail = &rest[idx + "/songs/".len()..];
let id: i64 = tail.parse().ok()?;
Some((name.to_string(), id))
}
fn parse_query(url: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
if let Some(q) = url.split('?').nth(1) {
@@ -305,8 +575,6 @@ fn parse_query(url: &str) -> std::collections::HashMap<String, String> {
out
}
/// Percent-decodes using `url` crate's `percent_encoding` so that UTF-8
/// sequences survive intact.
fn urldecode(s: &str) -> String {
use percent_encoding::percent_decode_str;
percent_decode_str(s)
@@ -350,3 +618,97 @@ fn name_from_body(body: &str) -> Result<String, String> {
}
Err("missing 'name' or 'song' in body".to_string())
}
fn songs_from_body(body: &str) -> Result<Vec<String>, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(arr) = v.get("songs").and_then(|x| x.as_array()) {
let mut out = Vec::with_capacity(arr.len());
for s in arr {
if let Some(s) = s.as_str() {
if !s.is_empty() {
out.push(s.to_string());
}
}
}
if !out.is_empty() {
return Ok(out);
}
}
Err("missing 'songs' array in body".to_string())
}
fn new_name_from_body(body: &str) -> Result<String, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(obj) = v.as_object() {
for key in ["name", "new_name"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
if !s.is_empty() {
return Ok(s.to_string());
}
}
}
}
Err("missing 'name' in body".to_string())
}
fn secs_from_body(body: &str) -> Result<f64, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(obj) = v.as_object() {
for key in ["secs", "seconds", "pos", "position"] {
if let Some(s) = obj.get(key) {
if let Some(n) = s.as_f64() {
return Ok(n);
}
if let Some(n) = s.as_i64() {
return Ok(n as f64);
}
}
}
}
Err("missing 'secs' in body".to_string())
}
fn vol_from_body(body: &str) -> Result<f32, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
let obj = v.as_object().ok_or_else(|| "expected JSON object".to_string())?;
for key in ["vol", "volume", "v"] {
if let Some(s) = obj.get(key) {
if let Some(n) = s.as_f64() {
return Ok(n as f32);
}
if let Some(n) = s.as_i64() {
return Ok(n as f32);
}
}
}
Err("missing 'volume' in body".to_string())
}
fn mode_from_body_or_default(body: &str) -> Result<RepeatMode, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
let obj = v
.as_object()
.ok_or_else(|| "expected JSON object".to_string())?;
for key in ["mode", "name"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
return RepeatMode::parse(s).ok_or_else(|| format!("invalid mode: {s}"));
}
}
Err("missing 'mode' in body".to_string())
}
fn reorder_from_body(body: &str) -> Result<(usize, usize), String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
let obj = v
.as_object()
.ok_or_else(|| "expected JSON object".to_string())?;
let from = obj
.get("from")
.and_then(|x| x.as_i64())
.ok_or_else(|| "missing integer 'from'".to_string())?;
let to = obj
.get("to")
.and_then(|x| x.as_i64())
.ok_or_else(|| "missing integer 'to'".to_string())?;
Ok((from as usize, to as usize))
}