Initial commit
This commit is contained in:
commit
e355dee61b
7 changed files with 2545 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
2168
Cargo.lock
generated
Normal file
2168
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 = "empede-tide"
|
||||||
|
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.70"
|
||||||
|
askama = "0.12.0"
|
||||||
|
askama_tide = "0.15.0"
|
||||||
|
async-std = { version = "1.12.0", features = ["attributes"] }
|
||||||
|
mpdrs = "0.1.0"
|
||||||
|
serde = { version = "1.0.160", features = ["derive"] }
|
||||||
|
serde_qs = "0.12.0"
|
||||||
|
tide = "0.16.0"
|
||||||
|
tide-tracing = "0.0.12"
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-subscriber = "0.3.17"
|
133
src/main.rs
Normal file
133
src/main.rs
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use askama::Template;
|
||||||
|
use async_std::prelude::*;
|
||||||
|
use async_std::{
|
||||||
|
io::{BufReader, WriteExt},
|
||||||
|
net::TcpStream,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
mod mpd;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
path: Vec<String>,
|
||||||
|
entries: Vec<mpd::Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct IndexQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(req: tide::Request<()>) -> tide::Result {
|
||||||
|
let query: IndexQuery = req.query()?;
|
||||||
|
let entries = mpd::ls(&query.path).await?;
|
||||||
|
let template = IndexTemplate {
|
||||||
|
path: Path::new(&query.path)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.collect(),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
Ok(askama_tide::into_response(&template))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "queue.html")]
|
||||||
|
struct QueueTemplate {
|
||||||
|
queue: Vec<mpd::QueueItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_queue(_req: tide::Request<()>) -> tide::Result {
|
||||||
|
let queue = mpd::playlist().await?;
|
||||||
|
let template = QueueTemplate { queue };
|
||||||
|
Ok(template.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostQueueQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_queue(req: tide::Request<()>) -> tide::Result {
|
||||||
|
let mut client = mpdrs::Client::connect(mpd::HOST)?;
|
||||||
|
let query: PostQueueQuery = req.query()?;
|
||||||
|
client.add(&query.path)?;
|
||||||
|
Ok("".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_play(_req: tide::Request<()>) -> tide::Result {
|
||||||
|
let mut mpd = mpdrs::Client::connect(mpd::HOST)?;
|
||||||
|
mpd.play()?;
|
||||||
|
Ok("".into())
|
||||||
|
}
|
||||||
|
async fn post_pause(_req: tide::Request<()>) -> tide::Result {
|
||||||
|
let mut mpd = mpdrs::Client::connect(mpd::HOST)?;
|
||||||
|
mpd.pause(true)?;
|
||||||
|
Ok("".into())
|
||||||
|
}
|
||||||
|
async fn post_previous(_req: tide::Request<()>) -> tide::Result {
|
||||||
|
let mut mpd = mpdrs::Client::connect(mpd::HOST)?;
|
||||||
|
mpd.prev()?;
|
||||||
|
Ok("".into())
|
||||||
|
}
|
||||||
|
async fn post_next(_req: tide::Request<()>) -> tide::Result {
|
||||||
|
let mut mpd = mpdrs::Client::connect(mpd::HOST)?;
|
||||||
|
mpd.next()?;
|
||||||
|
Ok("".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result<()> {
|
||||||
|
// Needs to be async and all async mpd libraries suck
|
||||||
|
let mut stream = TcpStream::connect(mpd::HOST).await?;
|
||||||
|
let mut reader = BufReader::new(stream.clone());
|
||||||
|
|
||||||
|
// skip OK MPD line
|
||||||
|
// TODO check
|
||||||
|
let mut buffer = String::new();
|
||||||
|
reader.read_line(&mut buffer).await?;
|
||||||
|
dbg!(&buffer);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
stream.write_all(b"idle playlist player\n").await?;
|
||||||
|
|
||||||
|
buffer.clear();
|
||||||
|
reader.read_line(&mut buffer).await?;
|
||||||
|
if buffer == "changed: playlist\n" {
|
||||||
|
sender.send("queue", "", None).await?;
|
||||||
|
} else if buffer == "changed: player\n" {
|
||||||
|
sender.send("player", "", None).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.clear();
|
||||||
|
reader.read_line(&mut buffer).await?;
|
||||||
|
if buffer != "OK\n" {
|
||||||
|
Err(anyhow!("mpd didn't return OK"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_std::main]
|
||||||
|
async fn main() -> tide::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::INFO)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let mut app = tide::new();
|
||||||
|
app.with(tide_tracing::TraceMiddleware::new());
|
||||||
|
app.at("/").get(index);
|
||||||
|
app.at("/queue").get(get_queue);
|
||||||
|
app.at("/queue").post(post_queue);
|
||||||
|
app.at("/play").post(post_play);
|
||||||
|
app.at("/pause").post(post_pause);
|
||||||
|
app.at("/previous").post(post_previous);
|
||||||
|
app.at("/next").post(post_next);
|
||||||
|
app.at("/sse").get(tide::sse::endpoint(sse));
|
||||||
|
app.listen("0.0.0.0:8080").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
74
src/mpd.rs
Normal file
74
src/mpd.rs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
use mpdrs::lsinfo::LsInfoResponse;
|
||||||
|
|
||||||
|
pub(crate) const HOST: &str = "192.168.1.203:6600";
|
||||||
|
|
||||||
|
pub(crate) async fn ls(path: &str) -> anyhow::Result<Vec<Entry>> {
|
||||||
|
// TODO mpdrs seems to be the only one to implement lsinfo
|
||||||
|
let mut mpd = mpdrs::Client::connect(HOST)?;
|
||||||
|
let info = mpd.lsinfo(path)?;
|
||||||
|
|
||||||
|
Ok(info
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
LsInfoResponse::Song(song) => Entry::Song {
|
||||||
|
name: song.title.as_ref().unwrap_or(&song.file).clone(),
|
||||||
|
artist: song.artist.clone().unwrap_or(String::new()),
|
||||||
|
path: song.file.clone(),
|
||||||
|
},
|
||||||
|
|
||||||
|
LsInfoResponse::Directory { path, .. } => {
|
||||||
|
let filename = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.map(|x| x.to_string_lossy().to_string())
|
||||||
|
.unwrap_or("n/a".to_string());
|
||||||
|
|
||||||
|
Entry::Directory {
|
||||||
|
name: filename,
|
||||||
|
path: path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LsInfoResponse::Playlist { path, .. } => Entry::Playlist {
|
||||||
|
name: path.to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct QueueItem {
|
||||||
|
pub(crate) title: String,
|
||||||
|
pub(crate) playing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn playlist() -> anyhow::Result<Vec<QueueItem>> {
|
||||||
|
let mut client = mpdrs::Client::connect(HOST)?;
|
||||||
|
|
||||||
|
let current = client.status()?.song;
|
||||||
|
|
||||||
|
let queue = client
|
||||||
|
.queue()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|song| QueueItem {
|
||||||
|
title: song.title.as_ref().unwrap_or(&song.file).clone(),
|
||||||
|
playing: current == song.place,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum Entry {
|
||||||
|
Song {
|
||||||
|
name: String,
|
||||||
|
artist: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
Directory {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
Playlist {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
}
|
141
templates/index.html
Normal file
141
templates/index.html
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
{# Template #}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Empede</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.0" integrity="sha384-aOxz9UdWG0yBiyrTwPeMibmaoq07/d3a96GCbb9x60f3mOt5zwkjdbcHFnKH8qls" crossorigin="anonymous"></script>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,1,0" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans;
|
||||||
|
background-color: #112;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.queue li {
|
||||||
|
padding: .5rem 0;
|
||||||
|
}
|
||||||
|
ul.queue li.playing {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
list-style: none;
|
||||||
|
background-color: #334;
|
||||||
|
border-radius: .25rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.breadcrumb li:not(:first-child)::before {
|
||||||
|
display: inline-block;
|
||||||
|
padding-left: .5rem;
|
||||||
|
padding-right: .5rem;
|
||||||
|
color: #6c757d;
|
||||||
|
content: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.dir li {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .75rem .75rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.dir li:hover {
|
||||||
|
background-color: #334;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.dir li .material-symbols-outlined {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song .song__name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.control {
|
||||||
|
font-size: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body hx-sse="connect:/sse">
|
||||||
|
<span
|
||||||
|
hx-post="/previous" hx-swap="none"
|
||||||
|
class="control material-symbols-outlined" role="button"
|
||||||
|
>skip_previous</span>
|
||||||
|
<span
|
||||||
|
hx-post="/play" hx-swap="none"
|
||||||
|
class="control material-symbols-outlined" role="button"
|
||||||
|
>play_arrow</span>
|
||||||
|
<span
|
||||||
|
hx-post="/pause" hx-swap="none"
|
||||||
|
class="control material-symbols-outlined" role="button"
|
||||||
|
>pause</span>
|
||||||
|
<span
|
||||||
|
hx-post="/next" hx-swap="none"
|
||||||
|
class="control material-symbols-outlined" role="button"
|
||||||
|
>skip_next</span>
|
||||||
|
|
||||||
|
<div hx-trigger="load,sse:queue,sse:player" hx-get="/queue"></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<ul class="breadcrumb">
|
||||||
|
<li><a href="?path=">Root</a></li>
|
||||||
|
{% for (i, component) in path.iter().enumerate() %}
|
||||||
|
<li>
|
||||||
|
{% if i == path.len() - 1 %}
|
||||||
|
{{ component }}
|
||||||
|
{% else %}
|
||||||
|
<a href="?path={{ path[..i + 1].join("/") }}">
|
||||||
|
{{ component }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="dir">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<li>
|
||||||
|
{% match entry %}
|
||||||
|
{% when mpd::Entry::Song with { name, path, artist } %}
|
||||||
|
<span class="material-symbols-outlined">music_note</span>
|
||||||
|
<div hx-post="/queue?path={{path}}" hx-swap="none" role="button" class="song">
|
||||||
|
<div class="song__name">{{ name }}</div>
|
||||||
|
<div class="song__artist">{{ artist }}</div>
|
||||||
|
</a>
|
||||||
|
{% when mpd::Entry::Directory with { name, path }%}
|
||||||
|
<span class="material-symbols-outlined">folder</span>
|
||||||
|
<a href="?path={{path}}">{{ name }}</a>
|
||||||
|
{% when mpd::Entry::Playlist with { name, path } %}
|
||||||
|
<span class="material-symbols-outlined">playlist_play</span>
|
||||||
|
<a href="?path={{path}}">{{ name }}</a>
|
||||||
|
{% endmatch %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
9
templates/queue.html
Normal file
9
templates/queue.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{# Template #}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<ul class="queue">
|
||||||
|
{% for item in queue %}
|
||||||
|
<li {% if item.playing %}class="playing"{% endif %}>
|
||||||
|
<input type="checkbox" /> {{ item.title }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
Loading…
Reference in a new issue