From 4465502ede3ddb44eb99b6fea45446a69c762492 Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Sat, 25 Jan 2025 20:50:17 +0100 Subject: [PATCH] api v2 start --- Cargo.lock | 1 + Cargo.toml | 1 + module.nix | 14 +- src/api.rs | 4 +- src/api/base.rs | 99 +++++++- src/api/rest_wrapper_v1.rs | 23 +- src/api/rest_wrapper_v2.rs | 493 +++++++++++++++++++++++++++++++++++++ src/api/websocket_v1.rs | 2 +- src/main.rs | 6 +- 9 files changed, 609 insertions(+), 34 deletions(-) create mode 100644 src/api/rest_wrapper_v2.rs diff --git a/Cargo.lock b/Cargo.lock index 3a12ab6..fa9284c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "tokio", "tower 0.4.13", "tower-http", + "url", "utoipa", "utoipa-axum", "utoipa-swagger-ui", diff --git a/Cargo.toml b/Cargo.toml index 7ae2593..a99ff8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +url = "2.2.2" swayipc = "3.0" anyhow = "1.0.82" axum = { version = "0.7.7", features = ["macros", "ws"] } diff --git a/module.nix b/module.nix index f2e8c33..6718389 100644 --- a/module.nix +++ b/module.nix @@ -168,13 +168,15 @@ in wrapperFeatures.gtk = true; }; - (lib.mkIf (cfg.enable && cfg.enableFirefox) { - programs.firefox = { - enable = true; - preferences = { - media.autoplay.default = "0"; + (lib.mkIf (cfg.enableFirefox) { + programs.firefox = { + enable = true; + preferences = { + media.autoplay.default = "0"; + }; }; - }; + environment.sessionVariables.DEFAULT_BROWSER = "${pkgs.firefox}/bin/firefox"; + }) xdg.portal = { enable = true; diff --git a/src/api.rs b/src/api.rs index 604e99e..145e5ae 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,8 @@ mod base; mod rest_wrapper_v1; +mod rest_wrapper_v2; mod websocket_v1; -pub use rest_wrapper_v1::{rest_api_docs, rest_api_routes}; +pub use rest_wrapper_v1::{rest_api_docs as rest_api_docs_v1, rest_api_routes as rest_api_routes_v1}; +pub use rest_wrapper_v2::{rest_api_docs as rest_api_docs_v2, rest_api_routes as rest_api_routes_v2}; pub use websocket_v1::websocket_api; diff --git a/src/api/base.rs b/src/api/base.rs index 2b36dd4..4c05cf8 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -173,7 +173,7 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()> use swayipc::{Connection, Fallible}; -pub async fn run_sway_command(command: String) -> Fallible<()> { +pub async fn sway_run_command(command: String) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; connection.run_command(&command)?; @@ -181,4 +181,99 @@ pub async fn run_sway_command(command: String) -> Fallible<()> { }) .await .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? -} \ No newline at end of file +} + + +//only to check if workspace exists. +fn get_workspace_names(connection: &mut Connection) -> Fallible> { + let workspaces = connection.get_workspaces()?; + Ok(workspaces.iter().map(|w| w.name.clone()).collect()) +} + +pub async fn sway_get_workspace_names() -> Fallible> { + tokio::task::spawn_blocking(|| -> Fallible> { + let mut connection = Connection::new()?; + get_workspace_names(&mut connection) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +fn is_valid_workspace(workspace: &str, connection: &mut Connection) -> Fallible { + let workspace_names = get_workspace_names(connection)?; + Ok(workspace_names.contains(&workspace.to_string()) || + workspace.parse::() + .map(|num| num >= 1 && num <= 10) + .unwrap_or(false)) +} + +pub async fn sway_change_workspace(workspace: String) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + + if !is_valid_workspace(&workspace, &mut connection)? { + return Err(swayipc::Error::CommandFailed( + "Invalid workspace name. Must be existing workspace or number 1-10".to_string() + )); + } + + connection.run_command(&format!("workspace {}", workspace))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +use url::Url; +pub async fn sway_launch_browser(url: &str) -> Fallible<()> { + // Validate URL + let url = Url::parse(url) + .map_err(|e| swayipc::Error::CommandFailed(format!("Invalid URL: {}", e)))?; + + // Ensure URL scheme is http or https + if url.scheme() != "http" && url.scheme() != "https" { + return Err(swayipc::Error::CommandFailed("URL must use http or https protocol".into())); + } + + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec xdg-open {}", url))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +pub async fn sway_close_workspace(workspace: String) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + + // Validate workspace exists + if !is_valid_workspace(&workspace, &mut connection)? { + return Err(swayipc::Error::CommandFailed( + "Invalid workspace name".to_string() + )); + } + + // Get workspace tree and find all nodes in target workspace + let tree = connection.get_tree()?; + let workspace_nodes = tree + .nodes + .iter() + .flat_map(|output| &output.nodes) // Get workspaces + .find(|ws| ws.name.as_ref().map_or(false, |name| name == &workspace)); + + // Kill all nodes in workspace if found + if let Some(ws) = workspace_nodes { + for container in ws.nodes.iter() { + // Close each container in the workspace + connection.run_command(&format!("[con_id={}] kill", container.id))?; + } + } + + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + diff --git a/src/api/rest_wrapper_v1.rs b/src/api/rest_wrapper_v1.rs index edb5ef1..8ad4a58 100644 --- a/src/api/rest_wrapper_v1.rs +++ b/src/api/rest_wrapper_v1.rs @@ -32,7 +32,6 @@ pub fn rest_api_routes(mpv: Mpv) -> Router { .route("/playlist/shuffle", post(shuffle)) .route("/playlist/loop", get(playlist_get_looping)) .route("/playlist/loop", post(playlist_set_looping)) - .route("/sway/command", post(sway_command)) .with_state(mpv) } @@ -52,7 +51,7 @@ pub fn rest_api_docs(mpv: Mpv) -> Router { .with_state(mpv) .split_for_parts(); - router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api)) + router.merge(SwaggerUi::new("/docs/v1").url("/docs/v1/openapi.json", api)) } // NOTE: the openapi stuff is very heavily duplicated and introduces @@ -402,23 +401,3 @@ async fn playlist_set_looping( ) -> RestResponse { base::playlist_set_looping(mpv, query.r#loop).await.into() } - - -#[derive(serde::Deserialize, utoipa::IntoParams)] -struct SwayCommandArgs { - command: String, -} - -/// Execute a sway command -#[utoipa::path( - post, - path = "/sway/command", - params(SwayCommandArgs), - responses( - (status = 200, description = "Success", body = EmptySuccessResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ) -)] -async fn sway_command(Query(query): Query) -> RestResponse { - base::run_sway_command(query.command).await.map_err(anyhow::Error::new).into() -} diff --git a/src/api/rest_wrapper_v2.rs b/src/api/rest_wrapper_v2.rs new file mode 100644 index 0000000..795992f --- /dev/null +++ b/src/api/rest_wrapper_v2.rs @@ -0,0 +1,493 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use mpvipc_async::Mpv; +use serde_json::{json, Value}; + +use utoipa::OpenApi; +use utoipa_axum::{router::OpenApiRouter, routes}; +use utoipa_swagger_ui::SwaggerUi; + +use super::base; + +pub fn rest_api_routes(mpv: Mpv) -> 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)) + .route("/sway/command", post(sway_run_command)) + .route("/sway/workspace/close", post(sway_close_workspace_handler)) + .route("/sway/workspace/change", post(sway_change_workspace_handler)) + .route("/sway/workspace/list", get(sway_get_workspace_names_handler)) + .route("/sway/browser/launch", post(sway_launch_browser_handler)) + .with_state(mpv) +} + +pub fn rest_api_docs(mpv: Mpv) -> Router { + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(loadfile)) + .routes(routes!(play_get, play_set)) + .routes(routes!(volume_get, volume_set)) + .routes(routes!(time_get, time_set)) + .routes(routes!(playlist_get, playlist_remove_or_clear)) + .routes(routes!(playlist_next)) + .routes(routes!(playlist_previous)) + .routes(routes!(playlist_goto)) + .routes(routes!(playlist_move)) + .routes(routes!(playlist_get_looping, playlist_set_looping)) + .routes(routes!(shuffle)) + .with_state(mpv) + .split_for_parts(); + + router.merge(SwaggerUi::new("/docs/v2").url("/docs/v2/openapi.json", api)) +} + +// NOTE: the openapi stuff is very heavily duplicated and introduces +// a lot of maintenance overhead and boilerplate. It should theoretically +// be possible to infer a lot of this from axum, but I haven't found a +// good library that does this and works properly yet (I have tried some +// but they all had issues). Feel free to replace this with a better solution. + +#[derive(OpenApi)] +#[openapi(info( + description = "The legacy Grzegorz Brzeczyszczykiewicz API, used to control a running mpv instance", + version = "1.0.0", +))] +struct ApiDoc; + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct EmptySuccessResponse { + success: bool, + error: bool, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct SuccessResponse { + #[schema(example = true)] + success: bool, + #[schema(example = false)] + error: bool, + #[schema(example = json!({ some: "arbitrary json value" }))] + value: Value, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct ErrorResponse { + #[schema(example = "error....")] + error: String, + #[schema(example = "error....")] + errortext: String, + #[schema(example = false)] + success: bool, +} + +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(), "errortext": err.to_string(), "success": false })), + ) + .into_response(), + } + } +} + +// -------------------// +// Boilerplate galore // +// -------------------// + +// TODO: These could possibly be generated with a proc macro + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct LoadFileArgs { + path: String, +} + +/// Add item to playlist +#[utoipa::path( + post, + path = "/load", + params(LoadFileArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn loadfile(State(mpv): State, Query(query): Query) -> RestResponse { + base::loadfile(mpv, &query.path).await.into() +} + +/// Check whether the player is paused or playing +#[utoipa::path( + get, + path = "/play", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn play_get(State(mpv): State) -> RestResponse { + base::play_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaySetArgs { + play: String, +} + +/// Set whether the player is paused or playing +#[utoipa::path( + post, + path = "/play", + params(PlaySetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +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() +} + +/// Get the current player volume +#[utoipa::path( + get, + path = "/volume", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn volume_get(State(mpv): State) -> RestResponse { + base::volume_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct VolumeSetArgs { + volume: f64, +} + +/// Set the player volume +#[utoipa::path( + post, + path = "/volume", + params(VolumeSetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn volume_set(State(mpv): State, Query(query): Query) -> RestResponse { + base::volume_set(mpv, query.volume).await.into() +} + +/// Get current playback position +#[utoipa::path( + get, + path = "/time", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn time_get(State(mpv): State) -> RestResponse { + base::time_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct TimeSetArgs { + pos: Option, + percent: Option, +} + +/// Set playback position +#[utoipa::path( + post, + path = "/time", + params(TimeSetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn time_set(State(mpv): State, Query(query): Query) -> RestResponse { + base::time_set(mpv, query.pos, query.percent).await.into() +} + +/// Get the current playlist +#[utoipa::path( + get, + path = "/playlist", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_get(State(mpv): State) -> RestResponse { + base::playlist_get(mpv).await.into() +} + +/// Go to the next item in the playlist +#[utoipa::path( + post, + path = "/playlist/next", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_next(State(mpv): State) -> RestResponse { + base::playlist_next(mpv).await.into() +} + +/// Go back to the previous item in the playlist +#[utoipa::path( + post, + path = "/playlist/previous", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_previous(State(mpv): State) -> RestResponse { + base::playlist_previous(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistGotoArgs { + index: usize, +} + +/// Go to a specific item in the playlist +#[utoipa::path( + post, + path = "/playlist/goto", + params(PlaylistGotoArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_goto( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_goto(mpv, query.index).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistRemoveOrClearArgs { + index: Option, +} + +/// Clears a single item or the entire playlist +#[utoipa::path( + delete, + path = "/playlist", + params(PlaylistRemoveOrClearArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +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, utoipa::IntoParams)] +struct PlaylistMoveArgs { + index1: usize, + index2: usize, +} + +/// Move a playlist item to a different position +#[utoipa::path( + post, + path = "/playlist/move", + params(PlaylistMoveArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_move( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_move(mpv, query.index1, query.index2) + .await + .into() +} + +/// Shuffle the playlist +#[utoipa::path( + post, + path = "/playlist/shuffle", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn shuffle(State(mpv): State) -> RestResponse { + base::shuffle(mpv).await.into() +} + +/// Check whether the playlist is looping +#[utoipa::path( + get, + path = "/playlist/loop", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_get_looping(State(mpv): State) -> RestResponse { + base::playlist_get_looping(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistSetLoopingArgs { + r#loop: bool, +} + +/// Set whether the playlist should loop +#[utoipa::path( + post, + path = "/playlist/loop", + params(PlaylistSetLoopingArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_set_looping( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_set_looping(mpv, query.r#loop).await.into() +} + + +/// Execute a sway command TODO: restrict to switching workspace, etc instead of arbitrary commands +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayCommandArgs { + command: String, +} +#[utoipa::path( + post, + path = "/sway/command", + params(SwayCommandArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_run_command(Query(query): Query) -> RestResponse { + base::sway_run_command(query.command).await.map_err(anyhow::Error::new).into() +} + + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayBrowserArgs { + url: String, +} +#[utoipa::path( + post, + path = "/sway/browser/launch", + params(SwayBrowserArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_launch_browser_handler(Query(query): Query) -> RestResponse { + base::sway_launch_browser(&query.url).await.map_err(anyhow::Error::new).into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayWorkspaceArgs { + workspace: String, +} + + +#[utoipa::path( + post, + path = "/sway/workspace/close", + params(SwayWorkspaceArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_close_workspace_handler(Query(query): Query) -> RestResponse { + base::sway_close_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +} + +#[utoipa::path( + post, + path = "/sway/workspace/change", + params(SwayWorkspaceArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_change_workspace_handler(Query(query): Query) -> RestResponse { + base::sway_change_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +} + +#[utoipa::path( + get, + path = "/sway/workspace/list", + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_get_workspace_names_handler() -> RestResponse { + base::sway_get_workspace_names().await + .map(|workspaces| json!(workspaces)) + .map_err(anyhow::Error::new) + .into() +} + diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 721d263..1ee2ff2 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -449,7 +449,7 @@ async fn handle_message( Ok(None) } WSCommand::SwayCommand { command } => { - base::run_sway_command(command).await?; + base::sway_run_command(command).await?; Ok(None) } } diff --git a/src/main.rs b/src/main.rs index bd9d29a..8e60342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,9 +231,11 @@ async fn main() -> anyhow::Result<()> { let id_pool = Arc::new(Mutex::new(IdPool::new_with_max_limit(1024))); let app = Router::new() - .nest("/api", api::rest_api_routes(mpv.clone())) + // .nest("/api/v1", api::rest_api_routes_v1(mpv.clone())) + .nest("/api/v2", api::rest_api_routes_v2(mpv.clone())) .nest("/ws", api::websocket_api(mpv.clone(), id_pool.clone())) - .merge(api::rest_api_docs(mpv.clone())) + // .merge(api::rest_api_docs_v1(mpv.clone())) + .merge(api::rest_api_docs_v2(mpv.clone())) .into_make_service_with_connect_info::(); let listener = match tokio::net::TcpListener::bind(&socket_addr)