backend api server

This commit is contained in:
L-Nafaryus 2024-09-26 01:04:50 +05:00
parent cf4411bd7c
commit 5878cf9d8c
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
6 changed files with 1512 additions and 0 deletions

8
.cargo/config.toml Normal file
View 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

File diff suppressed because it is too large Load Diff

27
Cargo.toml Normal file
View 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
View 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
View 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
View 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(())
}