musicdb/musicdb-server/src/web.rs
2023-09-11 14:15:24 +02:00

698 lines
26 KiB
Rust
Executable File

use std::convert::Infallible;
use std::mem;
use std::net::SocketAddr;
use std::sync::{mpsc, Arc, Mutex};
use std::task::Poll;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::response::sse::Event;
use axum::response::{Html, Sse};
use axum::routing::{get, post};
use axum::{Router, TypedHeader};
use futures::{stream, Stream};
use musicdb_lib::data::database::{Database, UpdateEndpoint};
use musicdb_lib::data::queue::{Queue, QueueContent};
use musicdb_lib::server::Command;
use tokio_stream::StreamExt as _;
/*
23E9 ⏩︎ fast forward
23EA ⏪︎ rewind, fast backwards
23EB ⏫︎ fast increase
23EC ⏬︎ fast decrease
23ED ⏭︎ skip to end, next
23EE ⏮︎ skip to start, previous
23EF ⏯︎ play/pause toggle
23F1 ⏱︎ stopwatch
23F2 ⏲︎ timer clock
23F3 ⏳︎ hourglass
23F4 ⏴︎ reverse, back
23F5 ⏵︎ forward, next, play
23F6 ⏶︎ increase
23F7 ⏷︎ decrease
23F8 ⏸︎ pause
23F9 ⏹︎ stop
23FA ⏺︎ record
*/
#[derive(Clone)]
pub struct AppState {
db: Arc<Mutex<Database>>,
html: Arc<AppHtml>,
}
#[derive(Debug)]
pub struct AppHtml {
/// /
/// can use:
root: Vec<HtmlPart>,
/// sse:artists
/// can use: artists (0+ repeats of artists_one)
artists: Vec<HtmlPart>,
/// can use: id, name
artists_one: Vec<HtmlPart>,
/// /artist-view/:artist-id
/// can use: albums (0+ repeats of albums_one)
artist_view: Vec<HtmlPart>,
/// can use: name
albums_one: Vec<HtmlPart>,
/// /album-view/:album-id
/// can use: id, name, songs (0+ repeats of songs_one)
album_view: Vec<HtmlPart>,
/// can use: title
songs_one: Vec<HtmlPart>,
/// /queue
/// can use: currentTitle, nextTitle, content
queue: Vec<HtmlPart>,
/// can use: path, title
queue_song: Vec<HtmlPart>,
/// can use: path, title
queue_song_current: Vec<HtmlPart>,
/// can use: path, content, name
queue_folder: Vec<HtmlPart>,
/// can use: path, content, name
queue_folder_current: Vec<HtmlPart>,
/// can use: path, total, current, inner
queue_loop: Vec<HtmlPart>,
/// can use: path, total, current, inner
queue_loop_current: Vec<HtmlPart>,
/// can use: path, current, inner
queue_loopinf: Vec<HtmlPart>,
/// can use: path, current, inner
queue_loopinf_current: Vec<HtmlPart>,
/// can use: path, content
queue_random: Vec<HtmlPart>,
/// can use: path, content
queue_random_current: Vec<HtmlPart>,
/// can use: path, content
queue_shuffle: Vec<HtmlPart>,
/// can use: path, content
queue_shuffle_current: Vec<HtmlPart>,
}
impl AppHtml {
pub fn from_dir<P: AsRef<std::path::Path>>(dir: P) -> std::io::Result<Self> {
let dir = dir.as_ref();
Ok(Self {
root: Self::parse(&std::fs::read_to_string(dir.join("root.html"))?),
artists: Self::parse(&std::fs::read_to_string(dir.join("artists.html"))?),
artists_one: Self::parse(&std::fs::read_to_string(dir.join("artists_one.html"))?),
artist_view: Self::parse(&std::fs::read_to_string(dir.join("artist-view.html"))?),
albums_one: Self::parse(&std::fs::read_to_string(dir.join("albums_one.html"))?),
album_view: Self::parse(&std::fs::read_to_string(dir.join("album-view.html"))?),
songs_one: Self::parse(&std::fs::read_to_string(dir.join("songs_one.html"))?),
queue: Self::parse(&std::fs::read_to_string(dir.join("queue.html"))?),
queue_song: Self::parse(&std::fs::read_to_string(dir.join("queue_song.html"))?),
queue_song_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_song_current.html"),
)?),
queue_folder: Self::parse(&std::fs::read_to_string(dir.join("queue_folder.html"))?),
queue_folder_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_folder_current.html"),
)?),
queue_loop: Self::parse(&std::fs::read_to_string(dir.join("queue_loop.html"))?),
queue_loop_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_loop_current.html"),
)?),
queue_loopinf: Self::parse(&std::fs::read_to_string(dir.join("queue_loopinf.html"))?),
queue_loopinf_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_loopinf_current.html"),
)?),
queue_random: Self::parse(&std::fs::read_to_string(dir.join("queue_random.html"))?),
queue_random_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_random_current.html"),
)?),
queue_shuffle: Self::parse(&std::fs::read_to_string(dir.join("queue_shuffle.html"))?),
queue_shuffle_current: Self::parse(&std::fs::read_to_string(
dir.join("queue_shuffle_current.html"),
)?),
})
}
pub fn parse(s: &str) -> Vec<HtmlPart> {
let mut o = Vec::new();
let mut c = String::new();
let mut chars = s.chars().peekable();
loop {
if let Some(ch) = chars.next() {
if ch == '\\' && chars.peek().is_some_and(|ch| *ch == ':') {
chars.next();
o.push(HtmlPart::Plain(mem::replace(&mut c, String::new())));
loop {
if let Some(ch) = chars.peek() {
if !ch.is_ascii_alphabetic() {
o.push(HtmlPart::Insert(mem::replace(&mut c, String::new())));
break;
} else {
c.push(*ch);
chars.next();
}
} else {
if c.len() > 0 {
o.push(HtmlPart::Insert(c));
}
return o;
}
}
} else {
c.push(ch);
}
} else {
if c.len() > 0 {
o.push(HtmlPart::Plain(c));
}
return o;
}
}
}
}
#[derive(Debug)]
pub enum HtmlPart {
/// text as plain html
Plain(String),
/// insert some value depending on context and key
Insert(String),
}
pub async fn main(db: Arc<Mutex<Database>>, sender: mpsc::Sender<Command>, addr: SocketAddr) {
let db1 = Arc::clone(&db);
let state = AppState {
db,
html: Arc::new(AppHtml::from_dir("assets").unwrap()),
};
let (s1, s2, s3, s4, s5, s6, s7, s8, s9) = (
sender.clone(),
sender.clone(),
sender.clone(),
sender.clone(),
sender.clone(),
sender.clone(),
sender.clone(),
sender.clone(),
sender,
);
let state1 = state.clone();
let app = Router::new()
// root
.nest_service(
"/",
get(move || async move {
Html(
state1
.html
.root
.iter()
.map(|v| match v {
HtmlPart::Plain(v) => v,
HtmlPart::Insert(_) => "",
})
.collect::<String>(),
)
}),
)
// server-sent events
.route("/sse", get(sse_handler))
// inner views (embedded in root)
.route("/artist-view/:artist-id", get(artist_view_handler))
.route("/album-view/:album-id", get(album_view_handler))
// handle POST requests via the mpsc::Sender instead of locking the db.
.route(
"/pause",
post(move || async move {
_ = s1.send(Command::Pause);
}),
)
.route(
"/resume",
post(move || async move {
_ = s2.send(Command::Resume);
}),
)
.route(
"/stop",
post(move || async move {
_ = s3.send(Command::Stop);
}),
)
.route(
"/next",
post(move || async move {
_ = s4.send(Command::NextSong);
}),
)
.route(
"/queue/clear",
post(move || async move {
_ = s5.send(Command::QueueUpdate(
vec![],
QueueContent::Folder(0, vec![], String::new()).into(),
));
}),
)
.route(
"/queue/remove/:i",
post(move |Path(i): Path<String>| async move {
let mut ids = vec![];
for id in i.split('-') {
if let Ok(n) = id.parse() {
ids.push(n);
} else {
return;
}
}
_ = s8.send(Command::QueueRemove(ids));
}),
)
.route(
"/queue/goto/:i",
post(move |Path(i): Path<String>| async move {
let mut ids = vec![];
for id in i.split('-') {
if let Ok(n) = id.parse() {
ids.push(n);
} else {
return;
}
}
_ = s9.send(Command::QueueGoto(ids));
}),
)
.route(
"/queue/add-song/:song-id",
post(move |Path(song_id)| async move {
_ = s6.send(Command::QueueAdd(
vec![],
QueueContent::Song(song_id).into(),
));
}),
)
.route(
"/queue/add-album/:album-id",
post(move |Path(album_id)| async move {
if let Some(album) = db1.lock().unwrap().albums().get(&album_id) {
_ = s7.send(Command::QueueAdd(
vec![],
QueueContent::Folder(
0,
album
.songs
.iter()
.map(|id| QueueContent::Song(*id).into())
.collect(),
album.name.clone(),
)
.into(),
));
}
}),
)
.with_state(state);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
async fn sse_handler(
TypedHeader(user_agent): TypedHeader<headers::UserAgent>,
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
println!("`{}` connected", user_agent.as_str());
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let mut db = state.db.lock().unwrap();
_ = sender.send(Arc::new(Command::SyncDatabase(vec![], vec![], vec![])));
_ = sender.send(Arc::new(Command::NextSong));
_ = sender.send(Arc::new(if db.playing {
Command::Resume
} else {
Command::Pause
}));
db.update_endpoints
.push(UpdateEndpoint::CmdChannelTokio(sender));
drop(db);
let stream = stream::poll_fn(move |_ctx| {
if let Ok(cmd) = receiver.try_recv() {
Poll::Ready(Some(match cmd.as_ref() {
Command::Resume => Event::default().event("playing").data("playing"),
Command::Pause => Event::default().event("playing").data("paused"),
Command::Stop => Event::default().event("playing").data("stopped"),
Command::SyncDatabase(..)
| Command::ModifySong(..)
| Command::ModifyAlbum(..)
| Command::ModifyArtist(..)
| Command::AddSong(..)
| Command::AddAlbum(..)
| Command::AddArtist(..)
| Command::AddCover(..)
| Command::RemoveSong(_)
| Command::RemoveAlbum(_)
| Command::RemoveArtist(_) => Event::default().event("artists").data({
let db = state.db.lock().unwrap();
let mut a = db.artists().iter().collect::<Vec<_>>();
a.sort_unstable_by_key(|(_id, artist)| &artist.name);
let mut artists = String::new();
for (id, artist) in a {
for v in &state.html.artists_one {
match v {
HtmlPart::Plain(v) => artists.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"id" => artists.push_str(&id.to_string()),
"name" => artists.push_str(&artist.name),
_ => {}
},
}
}
}
state
.html
.artists
.iter()
.map(|v| match v {
HtmlPart::Plain(v) => v,
HtmlPart::Insert(key) => match key.as_str() {
"artists" => &artists,
_ => "",
},
})
.collect::<String>()
}),
Command::NextSong
| Command::QueueUpdate(..)
| Command::QueueAdd(..)
| Command::QueueInsert(..)
| Command::QueueRemove(..)
| Command::QueueGoto(..)
| Command::QueueSetShuffle(..) => {
let db = state.db.lock().unwrap();
let current = db
.queue
.get_current_song()
.map_or(None, |id| db.songs().get(id));
let next = db
.queue
.get_next_song()
.map_or(None, |id| db.songs().get(id));
let mut content = String::new();
build_queue_content_build(
&db,
&state,
&mut content,
&db.queue,
String::new(),
true,
false,
);
Event::default().event("queue").data(
state
.html
.queue
.iter()
.map(|v| match v {
HtmlPart::Plain(v) => v,
HtmlPart::Insert(key) => match key.as_str() {
"currentTitle" => {
if let Some(s) = current {
&s.title
} else {
""
}
}
"nextTitle" => {
if let Some(s) = next {
&s.title
} else {
""
}
}
"content" => &content,
_ => "",
},
})
.collect::<String>(),
)
}
Command::Save | Command::SetLibraryDirectory(_) => return Poll::Pending,
}))
} else {
return Poll::Pending;
}
})
.map(Ok);
// .throttle(Duration::from_millis(100));
Sse::new(stream)
.keep_alive(axum::response::sse::KeepAlive::new().interval(Duration::from_millis(250)))
}
async fn artist_view_handler(
State(state): State<AppState>,
Path(artist_id): Path<u64>,
) -> Html<String> {
let db = state.db.lock().unwrap();
if let Some(artist) = db.artists().get(&artist_id) {
let mut albums = String::new();
for id in artist.albums.iter() {
if let Some(album) = db.albums().get(id) {
for v in &state.html.albums_one {
match v {
HtmlPart::Plain(v) => albums.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"id" => albums.push_str(&id.to_string()),
"name" => albums.push_str(&album.name),
_ => {}
},
}
}
}
}
let id = artist_id.to_string();
Html(
state
.html
.artist_view
.iter()
.map(|v| match v {
HtmlPart::Plain(v) => v,
HtmlPart::Insert(key) => match key.as_str() {
"id" => &id,
"name" => &artist.name,
"albums" => &albums,
_ => "",
},
})
.collect(),
)
} else {
Html(format!(
"<h1>Bad ID</h1><p>There is no artist with the id {artist_id} in the database</p>"
))
}
}
async fn album_view_handler(
State(state): State<AppState>,
Path(album_id): Path<u64>,
) -> Html<String> {
let db = state.db.lock().unwrap();
if let Some(album) = db.albums().get(&album_id) {
let mut songs = String::new();
for id in album.songs.iter() {
if let Some(song) = db.songs().get(id) {
for v in &state.html.songs_one {
match v {
HtmlPart::Plain(v) => songs.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"id" => songs.push_str(&id.to_string()),
"title" => songs.push_str(&song.title),
_ => {}
},
}
}
}
}
let id = album_id.to_string();
Html(
state
.html
.album_view
.iter()
.map(|v| match v {
HtmlPart::Plain(v) => v,
HtmlPart::Insert(key) => match key.as_str() {
"id" => &id,
"name" => &album.name,
"songs" => &songs,
_ => "",
},
})
.collect(),
)
} else {
Html(format!(
"<h1>Bad ID</h1><p>There is no album with the id {album_id} in the database</p>"
))
}
}
fn build_queue_content_build(
db: &Database,
state: &AppState,
html: &mut String,
queue: &Queue,
path: String,
current: bool,
skip_folder: bool,
) {
// TODO: Do something for disabled ones too (they shouldn't just be hidden)
if queue.enabled() {
match queue.content() {
QueueContent::Song(id) => {
if let Some(song) = db.songs().get(id) {
for v in if current {
&state.html.queue_song_current
} else {
&state.html.queue_song
} {
match v {
HtmlPart::Plain(v) => html.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"path" => html.push_str(&path),
"title" => html.push_str(&song.title),
_ => {}
},
}
}
}
}
QueueContent::Folder(ci, c, name) => {
if skip_folder || path.is_empty() {
for (i, c) in c.iter().enumerate() {
let current = current && *ci == i;
build_queue_content_build(db, state, html, c, i.to_string(), current, false)
}
} else {
for v in if current {
&state.html.queue_folder_current
} else {
&state.html.queue_folder
} {
match v {
HtmlPart::Plain(v) => html.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"path" => html.push_str(&path),
"name" => html.push_str(name),
"content" => {
for (i, c) in c.iter().enumerate() {
let current = current && *ci == i;
build_queue_content_build(
db,
state,
html,
c,
format!("{path}-{i}"),
current,
false,
)
}
}
_ => {}
},
}
}
}
}
QueueContent::Loop(total, cur, inner) => {
for v in match (*total, current) {
(0, false) => &state.html.queue_loopinf,
(0, true) => &state.html.queue_loopinf_current,
(_, false) => &state.html.queue_loop,
(_, true) => &state.html.queue_loop_current,
} {
match v {
HtmlPart::Plain(v) => html.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"path" => html.push_str(&path),
"total" => html.push_str(&format!("{total}")),
"current" => html.push_str(&format!("{cur}")),
"inner" => build_queue_content_build(
db,
state,
html,
&inner,
format!("{path}-0"),
current,
true,
),
_ => {}
},
}
}
}
QueueContent::Random(q) => {
for v in if current {
&state.html.queue_random_current
} else {
&state.html.queue_random
} {
match v {
HtmlPart::Plain(v) => html.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"path" => html.push_str(&path),
"content" => {
for (i, v) in q.iter().enumerate() {
build_queue_content_build(
db,
state,
html,
&v,
format!("{path}-0"),
current && i == q.len().saturating_sub(2),
true,
)
}
}
_ => {}
},
}
}
}
QueueContent::Shuffle(cur, map, content, _) => {
for v in if current {
&state.html.queue_shuffle_current
} else {
&state.html.queue_shuffle
} {
match v {
HtmlPart::Plain(v) => html.push_str(v),
HtmlPart::Insert(key) => match key.as_str() {
"path" => html.push_str(&path),
"content" => {
for (i, v) in map.iter().filter_map(|i| content.get(*i)).enumerate()
{
build_queue_content_build(
db,
state,
html,
&v,
format!("{path}-0"),
current && i == *cur,
true,
)
}
}
_ => {}
},
}
}
}
}
}
}