backend api server
This commit is contained in:
parent
cf4411bd7c
commit
5878cf9d8c
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[registry]
|
||||
default = "crates-io"
|
||||
|
||||
[registries.elnafo-vcs]
|
||||
index = "sparse+https://vcs.elnafo.ru/api/packages/L-Nafaryus/cargo/"
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
1116
Cargo.lock
generated
Normal file
1116
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
Normal file
27
Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "elnafo-radio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = ["elnafo-vcs"]
|
||||
authors = ["L-Nafaryus <l.nafaryus@elnafo.ru>"]
|
||||
description = "Elnafo radio"
|
||||
license = "MIT"
|
||||
repository = "https://vcs.elnafo.ru/L-Nafaryus/elnafo-radio"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.6", features = ["http2", "macros"] }
|
||||
#elnafo-radio-frontend = { version = "0.1.0", path = "crates/frontend" }
|
||||
mpd = "0.1.0"
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
time = "0.3.36"
|
||||
tokio = { version = "1.40.0", default-features = false, features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.8.19"
|
||||
tower-http = { version = "0.6.0", features = ["trace", "cors", "compression-gzip", "decompression-gzip"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
|
||||
|
||||
#[workspace]
|
||||
#members = ["crates/frontend"]
|
||||
#resolver = "2"
|
186
src/api.rs
Normal file
186
src/api.rs
Normal file
@ -0,0 +1,186 @@
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
extract::State,
|
||||
http::{header::*, Method, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use mpd::Client;
|
||||
use serde::ser::{Serialize, SerializeStruct, Serializer};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::Context;
|
||||
use crate::{
|
||||
config,
|
||||
config::{Station, StationId, StationStatus},
|
||||
};
|
||||
|
||||
pub fn routes(state: Arc<Context>) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers(vec![ORIGIN, AUTHORIZATION, ACCEPT, CONTENT_TYPE, COOKIE])
|
||||
.allow_origin([
|
||||
"http://localhost:54605".parse().unwrap(),
|
||||
"http://localhost:5173".parse().unwrap(),
|
||||
])
|
||||
.allow_credentials(true);
|
||||
|
||||
Router::new()
|
||||
.route("/healthcheck", get(healthcheck))
|
||||
.route("/stations", get(stations))
|
||||
.route("/status", get(status))
|
||||
.layer(cors)
|
||||
.fallback(fallback)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub enum Playback {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SongInfo {
|
||||
pub artist: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub tags: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct StationInfo {
|
||||
pub id: StationId,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub status: config::StationStatus,
|
||||
pub location: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub playback: Option<Playback>,
|
||||
}
|
||||
|
||||
impl Default for StationInfo {
|
||||
fn default() -> Self {
|
||||
StationInfo {
|
||||
id: StationId(String::from("station")),
|
||||
name: String::from("Station"),
|
||||
url: None,
|
||||
status: config::StationStatus::Receive,
|
||||
location: None,
|
||||
genre: None,
|
||||
playback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Station> for StationInfo {
|
||||
fn from(station: Station) -> Self {
|
||||
StationInfo {
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
url: station.url,
|
||||
status: station.status,
|
||||
location: station.location,
|
||||
genre: station.genre,
|
||||
..StationInfo::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stations(State(state): State<Arc<Context>>) -> (StatusCode, Json<Vec<StationInfo>>) {
|
||||
match &state.config.stations {
|
||||
Some(stations) => {
|
||||
let mut stations_info: Vec<StationInfo> = Vec::with_capacity(stations.len());
|
||||
|
||||
for station in stations {
|
||||
stations_info.push(match station.status {
|
||||
StationStatus::Online | StationStatus::Offline => {
|
||||
StationInfo::from(station.clone())
|
||||
}
|
||||
StationStatus::Receive => {
|
||||
let connection =
|
||||
Client::connect(format!("{}:{}", station.host, station.port));
|
||||
|
||||
if let Ok(mut client) = connection {
|
||||
let mut info = StationInfo::from(station.clone());
|
||||
|
||||
if let Ok(status) = client.status() {
|
||||
info.playback = match status.state {
|
||||
mpd::State::Play => Some(Playback::Playing),
|
||||
mpd::State::Stop => Some(Playback::Stopped),
|
||||
mpd::State::Pause => Some(Playback::Paused),
|
||||
};
|
||||
}
|
||||
|
||||
info
|
||||
} else {
|
||||
StationInfo::from(station.clone())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
(StatusCode::OK, Json(stations_info))
|
||||
}
|
||||
None => (StatusCode::OK, Json(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GetSongInfo {
|
||||
pub id: StationId,
|
||||
}
|
||||
|
||||
pub async fn status(
|
||||
State(state): State<Arc<Context>>,
|
||||
Json(body): Json<GetSongInfo>,
|
||||
) -> (StatusCode, Json<Option<SongInfo>>) {
|
||||
match &state.config.stations {
|
||||
Some(stations) => {
|
||||
for station in stations {
|
||||
if station.id.0.eq(&body.id.0) {
|
||||
let connection = Client::connect(format!("{}:{}", station.host, station.port));
|
||||
|
||||
if let Ok(mut client) = connection {
|
||||
if let Ok(Some(current)) = client.currentsong() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(Some(SongInfo {
|
||||
artist: current.artist,
|
||||
title: current.title,
|
||||
tags: current.tags,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
return (StatusCode::OK, Json(None));
|
||||
}
|
||||
} else {
|
||||
return (StatusCode::OK, Json(None));
|
||||
}
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, Json(None))
|
||||
}
|
||||
None => (StatusCode::OK, Json(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn healthcheck() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": StatusCode::OK.to_string(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn fallback() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"status": StatusCode::NOT_FOUND.to_string(),
|
||||
})),
|
||||
)
|
||||
}
|
121
src/config.rs
Normal file
121
src/config.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StationStatus {
|
||||
Online,
|
||||
Offline,
|
||||
Receive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StationId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Station {
|
||||
pub id: StationId,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub url: Option<String>,
|
||||
pub status: StationStatus,
|
||||
pub location: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
pub address: String,
|
||||
pub port: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: Server,
|
||||
pub stations: Option<Vec<Station>>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
Config::default()
|
||||
}
|
||||
|
||||
pub fn open(path: &std::path::Path) -> Result<Config, ConfigError> {
|
||||
fs::read_to_string(path)?.parse()
|
||||
}
|
||||
|
||||
pub fn data_dir() -> Result<std::path::PathBuf, ConfigError> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
if cfg!(debug_assertions) {
|
||||
Ok(cwd.join("temp"))
|
||||
} else {
|
||||
Ok(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> Result<String, ConfigError> {
|
||||
Ok(toml::to_string(self)?)
|
||||
}
|
||||
|
||||
pub fn write(&self, path: &std::path::Path) -> Result<(), ConfigError> {
|
||||
Ok(fs::write(path, self.to_string()?)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
server: Server {
|
||||
address: String::from("127.0.0.1"),
|
||||
port: 54605,
|
||||
},
|
||||
stations: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Config {
|
||||
type Err = ConfigError;
|
||||
fn from_str(s: &str) -> Result<Self, ConfigError> {
|
||||
toml::from_str(s).map_err(|_| ConfigError::Parse)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Parse,
|
||||
StringParse,
|
||||
Serialize,
|
||||
IO,
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Parse => write!(f, "Failed to parse Config from string"),
|
||||
Self::StringParse => write!(f, "Failed to parse environment variable"),
|
||||
Self::Serialize => write!(f, "Failed to serialize Config to TOML"),
|
||||
Self::IO => write!(f, "Faild to write file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::ser::Error> for ConfigError {
|
||||
fn from(_: toml::ser::Error) -> Self {
|
||||
ConfigError::Serialize
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ConfigError {
|
||||
fn from(_: std::io::Error) -> Self {
|
||||
ConfigError::IO
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::num::ParseIntError> for ConfigError {
|
||||
fn from(_: std::num::ParseIntError) -> Self {
|
||||
ConfigError::StringParse
|
||||
}
|
||||
}
|
54
src/main.rs
Normal file
54
src/main.rs
Normal file
@ -0,0 +1,54 @@
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
|
||||
use axum::{http::Uri, response::IntoResponse, routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::{self, TraceLayer};
|
||||
use tracing::Level;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub struct Context {
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
let config = match Config::open(Config::data_dir()?.join("config.toml").as_path()) {
|
||||
Ok(config) => {
|
||||
println!("Config loaded: {:?}", config);
|
||||
config
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{}", err);
|
||||
Config::new()
|
||||
}
|
||||
};
|
||||
|
||||
let state = Arc::new(Context {
|
||||
config: config.clone(),
|
||||
});
|
||||
|
||||
let app = Router::new().nest("/api", api::routes(state)).layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||
);
|
||||
|
||||
let address: SocketAddr =
|
||||
format!("{}:{}", config.server.address, config.server.port).parse()?;
|
||||
|
||||
let lister = tokio::net::TcpListener::bind(&address).await?;
|
||||
|
||||
println!("Listening on {}", address);
|
||||
|
||||
axum::serve(lister, app.into_make_service()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in New Issue
Block a user