add a bunch of qol features
This commit is contained in:
+407
-45
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user