From f2864f3023b9d8980d6477b957045a039475ac0d Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 15 Apr 2024 23:24:47 +0200 Subject: [PATCH] extract API logic into generic functions, and wrap REST API around --- src/api.rs | 312 +------------------------------------ src/api/base.rs | 189 ++++++++++++++++++++++ src/api/rest_wrapper_v1.rs | 204 ++++++++++++++++++++++++ src/app_error.rs | 29 ---- src/main.rs | 14 +- 5 files changed, 405 insertions(+), 343 deletions(-) create mode 100644 src/api/base.rs create mode 100644 src/api/rest_wrapper_v1.rs delete mode 100644 src/app_error.rs diff --git a/src/api.rs b/src/api.rs index dbbfe9b..1c16944 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,310 +1,4 @@ -use std::sync::Arc; -use tokio::sync::Mutex; +mod base; +mod rest_wrapper_v1; -use axum::{ - extract::{Query, State}, - response::IntoResponse, - routing::{delete, get, post}, - Json, Router, -}; -use mpvipc::{ - Mpv, NumberChangeOptions, Playlist, PlaylistAddOptions, PlaylistAddTypeOptions, SeekOptions, - Switch, -}; -use serde::Deserialize; -use serde_json::{json, Value}; - -type Result = std::result::Result; - -pub fn api_routes(mpv: Mpv) -> Router { - Router::new() - .route("/", get(index)) - .route("/load", post(loadfile)) - .route("/play", get(play_get)) - .route("/play", post(play_set)) - .route("/volume", get(volume_get)) - .route("/volume", post(volume_set)) - .route("/time", get(time_get)) - .route("/time", post(time_set)) - .route("/playlist", get(playlist_get)) - .route("/playlist/next", post(playlist_next)) - .route("/playlist/previous", post(playlist_previous)) - .route("/playlist/goto", post(playlist_goto)) - .route("/playlist/remove", delete(playlist_remove_or_clear)) - .route("/playlist/move", post(playlist_goto)) - .route("/playlist/shuffle", post(shuffle)) - .route("/playlist/loop", get(playlist_get_looping)) - .route("/playlist/loop", post(playlist_set_looping)) - .with_state(Arc::new(Mutex::new(mpv))) -} - -async fn index() -> &'static str { - "Hello friend, I hope you're having a lovely day" -} - -#[derive(Debug, Deserialize)] -struct APIRequestLoadFile { - // Link to the resource to enqueue - path: String, -} - -/// Add item to playlist -async fn loadfile( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /load {:?}", request); - - mpv.lock().await.playlist_add( - request.path.as_str(), - PlaylistAddTypeOptions::File, - PlaylistAddOptions::Append, - )?; - - Ok(Json(json!({ - "status": "true".to_string(), - "error": false, - }))) -} - -/// Check whether the player is paused or playing -async fn play_get(State(mpv): State>>) -> Result { - log::trace!("GET /play"); - - let paused: bool = mpv.lock().await.get_property("pause")?; - Ok(Json(json!({ - "value": paused, - "error": false, - }))) -} - -#[derive(Debug, Deserialize)] -struct APIRequestPlay { - value: bool, -} - -/// Set whether the player is paused or playing -async fn play_set( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /play {:?}", request); - - mpv.lock().await.set_property("pause", request.value)?; - - Ok(Json(json!({ - "error": false, - }))) -} - -/// Get the current player volume -async fn volume_get(State(mpv): State>>) -> Result { - log::trace!("GET /volume"); - - let volume: f64 = mpv.lock().await.get_property("volume")?; - - Ok(Json(json!({ - "value": volume, - "error": false, - }))) -} - -#[derive(Debug, Deserialize)] -struct APIRequestVolume { - value: f64, -} - -/// Set the player volume -async fn volume_set( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /volume {:?}", request); - - mpv.lock() - .await - .set_volume(request.value, NumberChangeOptions::Absolute)?; - - Ok(Json(json!({ - "error": false, - }))) -} - -/// Get current playback position -async fn time_get(State(mpv): State>>) -> Result { - log::trace!("GET /time"); - - let current: f64 = mpv.lock().await.get_property("time-pos")?; - let remaining: f64 = mpv.lock().await.get_property("time-remaining")?; - let total = current + remaining; - - Ok(Json(json!({ - "value": { - "current": current, - "remaining": remaining, - "total": total, - }, - "error": false, - }))) -} - -#[derive(Debug, Deserialize)] -struct APIRequestTime { - pos: Option, - percent: Option, -} - -/// Set playback position -async fn time_set( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /time {:?}", request); - - if request.pos.is_some() && request.percent.is_some() { - return Err(crate::app_error::AppError(anyhow::anyhow!( - "pos and percent cannot be provided at the same time" - ))); - } - - if let Some(pos) = request.pos { - mpv.lock().await.seek(pos, SeekOptions::Absolute)?; - } else if let Some(percent) = request.percent { - mpv.lock() - .await - .seek(percent, SeekOptions::AbsolutePercent)?; - } else { - return Err(crate::app_error::AppError(anyhow::anyhow!( - "Either pos or percent must be provided" - ))); - }; - - Ok(Json(json!({ - "error": false, - }))) -} - -/// Get the current playlist -async fn playlist_get(State(mpv): State>>) -> Result { - log::trace!("GET /playlist"); - - let playlist: Playlist = mpv.lock().await.get_playlist()?; - let is_playing: bool = mpv.lock().await.get_property("pause")?; - - let items: Vec = playlist - .0 - .iter() - .enumerate() - .map(|(i, item)| { - json!({ - "index": i, - "current": item.current, - "playing": is_playing, - "filename": item.filename, - "data": { - "fetching": true, - } - }) - }) - .collect(); - - Ok(Json(json!({ - "value": items, - "error": false, - }))) -} - -/// Skip to the next item in the playlist -async fn playlist_next(State(mpv): State>>) -> Result { - log::trace!("POST /playlist/next"); - - Ok(Json(json!({ - "status": mpv.lock().await.next().is_ok().to_string(), - "error": false, - }))) -} - -/// Go back to the previous item in the playlist -async fn playlist_previous(State(mpv): State>>) -> Result { - log::trace!("POST /playlist/previous"); - - Ok(Json(json!({ - "status": mpv.lock().await.prev().is_ok().to_string(), - "error": false, - }))) -} - -#[derive(Debug, Deserialize)] -struct APIRequestPlaylistGoto { - index: usize, -} - -/// Go chosen item in the playlist -async fn playlist_goto( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /playlist/goto {:?}", request); - - Ok(Json(json!({ - "status": mpv.lock().await.playlist_play_id(request.index).is_ok().to_string(), - "error": false, - }))) -} - -/// Clears single item or whole playlist -async fn playlist_remove_or_clear(State(mpv): State>>) -> Result { - log::trace!("DELETE /playlist/remove"); - - Ok(Json(json!({ - "status": mpv.lock().await.playlist_clear().is_ok().to_string(), - "error": false, - }))) -} - -/// Shuffle the playlist -async fn shuffle(State(mpv): State>>) -> Result { - log::trace!("POST /playlist/shuffle"); - - Ok(Json(json!({ - "status": mpv.lock().await.playlist_shuffle().is_ok().to_string(), - "error": false, - }))) -} - -/// See whether it loops the playlist or not -async fn playlist_get_looping(State(mpv): State>>) -> Result { - log::trace!("GET /playlist/loop"); - - // TODO: this needs to be updated in the next version of the API - // let loop_file: bool = mpv.lock().await.get_property("loop-file").unwrap(); - let loop_playlist: bool = mpv.lock().await.get_property("loop-playlist")?; - - Ok(Json(json!({ - "value": loop_playlist, - "error": false, - }))) -} - -#[derive(Debug, Deserialize)] -struct APIRequestPlaylistSetLooping { - r#loop: bool, -} - -async fn playlist_set_looping( - State(mpv): State>>, - Query(request): Query, -) -> Result { - log::trace!("POST /playlist/loop {:?}", request); - - if request.r#loop { - mpv.lock().await.set_loop_playlist(Switch::On)?; - } else { - mpv.lock().await.set_loop_playlist(Switch::Off)?; - } - - Ok(Json(json!({ - "status": request.r#loop.to_string(), - "error": false, - }))) -} +pub use rest_wrapper_v1::rest_api_routes; diff --git a/src/api/base.rs b/src/api/base.rs new file mode 100644 index 0000000..9d8b9ea --- /dev/null +++ b/src/api/base.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use log::trace; +use mpvipc::{ + Mpv, NumberChangeOptions, PlaylistAddOptions, PlaylistAddTypeOptions, SeekOptions, Switch, +}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +/// Add item to playlist +pub async fn loadfile(mpv: Arc>, path: &str) -> anyhow::Result<()> { + trace!("api::loadfile({:?})", path); + mpv.lock().await.playlist_add( + path, + PlaylistAddTypeOptions::File, + PlaylistAddOptions::Append, + )?; + + Ok(()) +} + +/// Check whether the player is paused or playing +pub async fn play_get(mpv: Arc>) -> anyhow::Result { + trace!("api::play_get()"); + let paused: bool = mpv.lock().await.get_property("pause")?; + Ok(json!(!paused)) +} + +/// Set whether the player is paused or playing +pub async fn play_set(mpv: Arc>, should_play: bool) -> anyhow::Result<()> { + trace!("api::play_set({:?})", should_play); + mpv.lock() + .await + .set_property("pause", !should_play) + .map_err(|e| e.into()) +} + +/// Get the current player volume +pub async fn volume_get(mpv: Arc>) -> anyhow::Result { + trace!("api::volume_get()"); + let volume: f64 = mpv.lock().await.get_property("volume")?; + Ok(json!(volume)) +} + +/// Set the player volume +pub async fn volume_set(mpv: Arc>, value: f64) -> anyhow::Result<()> { + trace!("api::volume_set({:?})", value); + mpv.lock() + .await + .set_volume(value, NumberChangeOptions::Absolute) + .map_err(|e| e.into()) +} + +/// Get current playback position +pub async fn time_get(mpv: Arc>) -> anyhow::Result { + trace!("api::time_get()"); + let current: f64 = mpv.lock().await.get_property("time-pos")?; + let remaining: f64 = mpv.lock().await.get_property("time-remaining")?; + let total = current + remaining; + + Ok(json!({ + "current": current, + "remaining": remaining, + "total": total, + })) +} + +/// Set playback position +pub async fn time_set( + mpv: Arc>, + pos: Option, + percent: Option, +) -> anyhow::Result<()> { + trace!("api::time_set({:?}, {:?})", pos, percent); + if pos.is_some() && percent.is_some() { + anyhow::bail!("pos and percent cannot be provided at the same time"); + } + + if let Some(pos) = pos { + mpv.lock().await.seek(pos, SeekOptions::Absolute)?; + } else if let Some(percent) = percent { + mpv.lock() + .await + .seek(percent, SeekOptions::AbsolutePercent)?; + } else { + anyhow::bail!("Either pos or percent must be provided"); + }; + + Ok(()) +} + +/// Get the current playlist +pub async fn playlist_get(mpv: Arc>) -> anyhow::Result { + trace!("api::playlist_get()"); + let playlist: mpvipc::Playlist = mpv.lock().await.get_playlist()?; + let is_playing: bool = mpv.lock().await.get_property("pause")?; + + let items: Vec = playlist + .0 + .iter() + .enumerate() + .map(|(i, item)| { + json!({ + "index": i, + "current": item.current, + "playing": is_playing, + "filename": item.filename, + "data": { + "fetching": true, + } + }) + }) + .collect(); + + Ok(json!(items)) +} + +/// Skip to the next item in the playlist +pub async fn playlist_next(mpv: Arc>) -> anyhow::Result<()> { + trace!("api::playlist_next()"); + mpv.lock().await.next().map_err(|e| e.into()) +} + +/// Go back to the previous item in the playlist +pub async fn playlist_previous(mpv: Arc>) -> anyhow::Result<()> { + trace!("api::playlist_previous()"); + mpv.lock().await.prev().map_err(|e| e.into()) +} + +/// Go chosen item in the playlist +pub async fn playlist_goto(mpv: Arc>, index: usize) -> anyhow::Result<()> { + trace!("api::playlist_goto({:?})", index); + mpv.lock() + .await + .playlist_play_id(index) + .map_err(|e| e.into()) +} + +/// Clears the playlist +pub async fn playlist_clear(mpv: Arc>) -> anyhow::Result<()> { + trace!("api::playlist_clear()"); + mpv.lock().await.playlist_clear().map_err(|e| e.into()) +} + +/// Remove an item from the playlist by index +pub async fn playlist_remove(mpv: Arc>, index: usize) -> anyhow::Result<()> { + trace!("api::playlist_remove({:?})", index); + mpv.lock() + .await + .playlist_remove_id(index) + .map_err(|e| e.into()) +} + +/// Move an item in the playlist from one index to another +pub async fn playlist_move(mpv: Arc>, from: usize, to: usize) -> anyhow::Result<()> { + trace!("api::playlist_move({:?}, {:?})", from, to); + mpv.lock() + .await + .playlist_move_id(from, to) + .map_err(|e| e.into()) +} + +/// Shuffle the playlist +pub async fn shuffle(mpv: Arc>) -> anyhow::Result<()> { + trace!("api::shuffle()"); + mpv.lock().await.playlist_shuffle().map_err(|e| e.into()) +} + +/// See whether it loops the playlist or not +pub async fn playlist_get_looping(mpv: Arc>) -> anyhow::Result { + trace!("api::playlist_get_looping()"); + let loop_playlist = mpv.lock().await.get_property_string("loop-playlist")? == "inf"; + Ok(json!(loop_playlist)) +} + +pub async fn playlist_set_looping(mpv: Arc>, r#loop: bool) -> anyhow::Result<()> { + trace!("api::playlist_set_looping({:?})", r#loop); + if r#loop { + mpv.lock() + .await + .set_loop_playlist(Switch::On) + .map_err(|e| e.into()) + } else { + mpv.lock() + .await + .set_loop_playlist(Switch::Off) + .map_err(|e| e.into()) + } +} diff --git a/src/api/rest_wrapper_v1.rs b/src/api/rest_wrapper_v1.rs new file mode 100644 index 0000000..6803697 --- /dev/null +++ b/src/api/rest_wrapper_v1.rs @@ -0,0 +1,204 @@ +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use mpvipc::Mpv; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use super::base; + +pub fn rest_api_routes(mpv: Arc>) -> Router { + Router::new() + .route("/load", post(loadfile)) + .route("/play", get(play_get)) + .route("/play", post(play_set)) + .route("/volume", get(volume_get)) + .route("/volume", post(volume_set)) + .route("/time", get(time_get)) + .route("/time", post(time_set)) + .route("/playlist", get(playlist_get)) + .route("/playlist/next", post(playlist_next)) + .route("/playlist/previous", post(playlist_previous)) + .route("/playlist/goto", post(playlist_goto)) + .route("/playlist", delete(playlist_remove_or_clear)) + .route("/playlist/move", post(playlist_move)) + .route("/playlist/shuffle", post(shuffle)) + .route("/playlist/loop", get(playlist_get_looping)) + .route("/playlist/loop", post(playlist_set_looping)) + .with_state(mpv) +} + +pub struct RestResponse(anyhow::Result); + +impl From> for RestResponse { + fn from(result: anyhow::Result) -> Self { + Self(result.map(|value| json!({ "success": true, "error": false, "value": value }))) + } +} + +impl From> for RestResponse { + fn from(result: anyhow::Result<()>) -> Self { + Self(result.map(|_| json!({ "success": true, "error": false }))) + } +} + +impl IntoResponse for RestResponse { + fn into_response(self) -> Response { + match self.0 { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": err.to_string(), "success": false })), + ) + .into_response(), + } + } +} + +// -------------------// +// Boilerplate galore // +// -------------------// + +// TODO: These could possibly be generated with a proc macro + +#[derive(serde::Deserialize)] +struct LoadFileArgs { + path: String, +} + +async fn loadfile( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::loadfile(mpv, &query.path).await.into() +} + +async fn play_get(State(mpv): State>>) -> RestResponse { + base::play_get(mpv).await.into() +} + +#[derive(serde::Deserialize)] +struct PlaySetArgs { + play: String, +} + +async fn play_set( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + let play = query.play.to_lowercase() == "true"; + base::play_set(mpv, play).await.into() +} + +async fn volume_get(State(mpv): State>>) -> RestResponse { + base::volume_get(mpv).await.into() +} + +#[derive(serde::Deserialize)] +struct VolumeSetArgs { + volume: f64, +} + +async fn volume_set( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::volume_set(mpv, query.volume).await.into() +} + +async fn time_get(State(mpv): State>>) -> RestResponse { + base::time_get(mpv).await.into() +} + +#[derive(serde::Deserialize)] +struct TimeSetArgs { + pos: Option, + percent: Option, +} + +async fn time_set( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::time_set(mpv, query.pos, query.percent).await.into() +} + +async fn playlist_get(State(mpv): State>>) -> RestResponse { + base::playlist_get(mpv).await.into() +} + +async fn playlist_next(State(mpv): State>>) -> RestResponse { + base::playlist_next(mpv).await.into() +} + +async fn playlist_previous(State(mpv): State>>) -> RestResponse { + base::playlist_previous(mpv).await.into() +} + +#[derive(serde::Deserialize)] +struct PlaylistGotoArgs { + index: usize, +} + +async fn playlist_goto( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::playlist_goto(mpv, query.index).await.into() +} + +#[derive(serde::Deserialize)] +struct PlaylistRemoveOrClearArgs { + index: Option, +} + +async fn playlist_remove_or_clear( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + match query.index { + Some(index) => base::playlist_remove(mpv, index).await.into(), + None => base::playlist_clear(mpv).await.into(), + } +} + +#[derive(serde::Deserialize)] +struct PlaylistMoveArgs { + index1: usize, + index2: usize, +} + +async fn playlist_move( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::playlist_move(mpv, query.index1, query.index2) + .await + .into() +} + +async fn shuffle(State(mpv): State>>) -> RestResponse { + base::shuffle(mpv).await.into() +} + +async fn playlist_get_looping(State(mpv): State>>) -> RestResponse { + base::playlist_get_looping(mpv).await.into() +} + +#[derive(serde::Deserialize)] +struct PlaylistSetLoopingArgs { + r#loop: bool, +} + +async fn playlist_set_looping( + State(mpv): State>>, + Query(query): Query, +) -> RestResponse { + base::playlist_set_looping(mpv, query.r#loop).await.into() +} diff --git a/src/app_error.rs b/src/app_error.rs deleted file mode 100644 index 8da927f..0000000 --- a/src/app_error.rs +++ /dev/null @@ -1,29 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, -}; - -// Make our own error that wraps `anyhow::Error`. -pub struct AppError(pub anyhow::Error); - -// Tell axum how to convert `AppError` into a response. -impl IntoResponse for AppError { - fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {}", self.0), - ) - .into_response() - } -} - -// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into -// `Result<_, AppError>`. That way you don't need to do that manually. -impl From for AppError -where - E: Into, -{ - fn from(err: E) -> Self { - Self(err.into()) - } -} diff --git a/src/main.rs b/src/main.rs index a029251..a4e9223 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,14 @@ use std::{ fs::create_dir_all, net::{IpAddr, SocketAddr}, path::Path, + sync::Arc, +}; +use tokio::{ + process::{Child, Command}, + sync::Mutex, }; -use tokio::process::{Child, Command}; mod api; -mod app_error; #[derive(Parser)] struct Args { @@ -121,8 +124,8 @@ async fn resolve(host: &str) -> anyhow::Result { let addr = format!("{}:0", host); let addresses = tokio::net::lookup_host(addr).await?; addresses - .filter(|addr| addr.is_ipv4()) - .next() + .into_iter() + .find(|addr| addr.is_ipv4()) .map(|addr| addr.ip()) .ok_or_else(|| anyhow::anyhow!("Failed to resolve address")) } @@ -143,7 +146,8 @@ async fn main() -> anyhow::Result<()> { let addr = SocketAddr::new(resolve(&args.host).await?, args.port); log::info!("Starting API on {}", addr); - let app = Router::new().nest("/api", api::api_routes(mpv)); + let mpv = Arc::new(Mutex::new(mpv)); + let app = Router::new().nest("/api", api::rest_api_routes(mpv)); if let Some(mut proc) = proc { tokio::select! {