Initial commit
This commit is contained in:
parent
a8ca5c3677
commit
cc32fb9a2d
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
1361
Cargo.lock
generated
Normal file
1361
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "greg-ng"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.82"
|
||||
axum = { version = "0.6.20", features = ["macros"] }
|
||||
clap = { version = "4.4.1", features = ["derive"] }
|
||||
env_logger = "0.10.0"
|
||||
log = "0.4.20"
|
||||
mpvipc = "1.3.0"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
tower = { version = "0.4.13", features = ["full"] }
|
||||
tower-http = { version = "0.4.3", features = ["full"] }
|
20
README.md
20
README.md
@ -1,3 +1,19 @@
|
||||
# greg-ng
|
||||
# Greg-ng
|
||||
|
||||
Georg has never been this polish
|
||||
New implementation of https://github.com/Programvareverkstedet/grzegorz
|
||||
|
||||
## Feature wishlist
|
||||
|
||||
- [ ] Save playlists to machine
|
||||
- [ ] Cache playlist contents to disk
|
||||
- [ ] Expose service through mpd protocol
|
||||
- [ ] Users with playlists and songs (and auth?)
|
||||
- [ ] Some kind of fair scheduling for each user
|
||||
- [ ] Max time to avoid playlist songs
|
||||
- [ ] Expose video/media stream so others can listen at home
|
||||
- [ ] Syncope support >:)
|
||||
- [ ] Jitsi support >:)))
|
||||
- [ ] Show other media while playing music, like grafana or bustimes
|
||||
- [ ] Soft shuffle
|
||||
- [ ] Libre.fm integration
|
||||
- [ ] Karaoke mode lmao
|
||||
|
310
src/api.rs
Normal file
310
src/api.rs
Normal file
@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
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<T, E = crate::app_error::AppError> = std::result::Result<T, E>;
|
||||
|
||||
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<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestLoadFile>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestPlay>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestVolume>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<f64>,
|
||||
percent: Option<f64>,
|
||||
}
|
||||
|
||||
/// Set playback position
|
||||
async fn time_set(
|
||||
State(mpv): State<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestTime>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Value> = 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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestPlaylistGoto>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>) -> Result<impl IntoResponse> {
|
||||
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<Arc<Mutex<Mpv>>>,
|
||||
Query(request): Query<APIRequestPlaylistSetLooping>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
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,
|
||||
})))
|
||||
}
|
29
src/app_error.rs
Normal file
29
src/app_error.rs
Normal file
@ -0,0 +1,29 @@
|
||||
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<E> From<E> for AppError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
161
src/main.rs
Normal file
161
src/main.rs
Normal file
@ -0,0 +1,161 @@
|
||||
use anyhow::Context;
|
||||
use axum::{Router, Server};
|
||||
use clap::Parser;
|
||||
use mpvipc::Mpv;
|
||||
use std::{fs::create_dir_all, net::SocketAddr, path::Path};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
mod api;
|
||||
mod app_error;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
#[clap(long, default_value = "localhost")]
|
||||
host: String,
|
||||
|
||||
#[clap(short, long, default_value = "8008")]
|
||||
port: u16,
|
||||
|
||||
#[clap(long, value_name = "PATH", default_value = "/run/mpv/mpv.sock")]
|
||||
mpv_socket_path: String,
|
||||
|
||||
#[clap(long, value_name = "PATH")]
|
||||
mpv_executable_path: Option<String>,
|
||||
|
||||
#[clap(short, long, default_value = "true")]
|
||||
auto_start_mpv: bool,
|
||||
|
||||
#[clap(long, default_value = "true")]
|
||||
force_auto_start: bool,
|
||||
}
|
||||
|
||||
struct MpvConnectionArgs {
|
||||
socket_path: String,
|
||||
executable_path: Option<String>,
|
||||
auto_start: bool,
|
||||
force_auto_start: bool,
|
||||
}
|
||||
|
||||
async fn connect_to_mpv(args: &MpvConnectionArgs) -> anyhow::Result<(Mpv, Option<Child>)> {
|
||||
log::debug!("Connecting to mpv");
|
||||
|
||||
debug_assert!(
|
||||
!args.force_auto_start || args.auto_start,
|
||||
"force_auto_start requires auto_start"
|
||||
);
|
||||
|
||||
let socket_path = Path::new(&args.socket_path);
|
||||
|
||||
if !socket_path.exists() {
|
||||
log::debug!("Mpv socket not found at {}", &args.socket_path);
|
||||
if !args.auto_start {
|
||||
panic!("Mpv socket not found at {}", &args.socket_path);
|
||||
}
|
||||
|
||||
log::debug!("Ensuring parent dir of mpv socket exists");
|
||||
let parent_dir = Path::new(&args.socket_path)
|
||||
.parent()
|
||||
.context("Failed to get parent dir of mpv socket")?;
|
||||
|
||||
if !parent_dir.is_dir() {
|
||||
create_dir_all(parent_dir).context("Failed to create parent dir of mpv socket")?;
|
||||
}
|
||||
} else {
|
||||
log::debug!("Existing mpv socket found at {}", &args.socket_path);
|
||||
if args.force_auto_start {
|
||||
log::debug!("Removing mpv socket");
|
||||
std::fs::remove_file(&args.socket_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
let process_handle = if args.auto_start {
|
||||
log::info!("Starting mpv with socket at {}", &args.socket_path);
|
||||
|
||||
// TODO: try to fetch mpv from PATH
|
||||
Some(
|
||||
Command::new(args.executable_path.as_deref().unwrap_or("mpv"))
|
||||
.arg(format!("--input-ipc-server={}", &args.socket_path))
|
||||
.arg("--idle")
|
||||
.arg("--force-window")
|
||||
// .arg("--fullscreen")
|
||||
// .arg("--no-terminal")
|
||||
// .arg("--load-unsafe-playlists")
|
||||
.arg("--keep-open") // Keep last frame of video on end of video
|
||||
.spawn()
|
||||
.context("Failed to start mpv")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wait for mpv to create the socket
|
||||
if tokio::time::timeout(tokio::time::Duration::from_millis(500), async {
|
||||
while !&socket_path.exists() {
|
||||
log::debug!("Waiting for mpv socket at {}", &args.socket_path);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to connect to mpv socket: {}",
|
||||
&args.socket_path
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
Mpv::connect(&args.socket_path).context(format!(
|
||||
"Failed to connect to mpv socket: {}",
|
||||
&args.socket_path
|
||||
))?,
|
||||
process_handle,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let (mpv, proc) = connect_to_mpv(&MpvConnectionArgs {
|
||||
socket_path: args.mpv_socket_path,
|
||||
executable_path: args.mpv_executable_path,
|
||||
auto_start: args.auto_start_mpv,
|
||||
force_auto_start: args.force_auto_start,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// TODO: fix address
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
|
||||
log::info!("Starting API on {}", addr);
|
||||
|
||||
let app = Router::new().nest("/api", api::api_routes(mpv));
|
||||
|
||||
if let Some(mut proc) = proc {
|
||||
tokio::select! {
|
||||
exit_status = proc.wait() => {
|
||||
log::warn!("mpv process exited with status: {}", exit_status?);
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
log::info!("Received Ctrl-C, exiting");
|
||||
proc.kill().await?;
|
||||
}
|
||||
_ = Server::bind(&addr.clone()).serve(app.into_make_service()) => {
|
||||
log::info!("API server exited");
|
||||
proc.kill().await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
log::info!("Received Ctrl-C, exiting");
|
||||
}
|
||||
_ = Server::bind(&addr.clone()).serve(app.into_make_service()) => {
|
||||
log::info!("API server exited");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in New Issue
Block a user