First prototype

This commit is contained in:
2025-03-14 00:31:04 +01:00
commit d453a3aafa
12 changed files with 3581 additions and 0 deletions

2
.codespellrc Normal file
View File

@@ -0,0 +1,2 @@
[codespell]
ignore-words-list = ratatui,crate

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
PASSWORD="SuperStrongPassword42"
USERNAME="bob"
URL="https://music.yourdomain.com"

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

2964
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "akula"
version = "0.1.0"
edition = "2024"
[dependencies]
log = "0.4"
color-eyre = "0.6"
dotenv = "0.15"
serde = { version = "1", features = ["derive"]}
itertools = "0.14"
ratatui = "0.29"
crossterm = { version = "0.28.1", features = ["event-stream","serde"] }
tokio = { version = "1.43", features = ["macros", "rt"] }
tokio-stream = "0.1"
tokio-util = "0.7"
futures = "0.3"
reqwest = { version = "0.12", features = ["blocking", "stream"] }
subsonic-types = "0.2.0"
rand = "0.9"
md5 = "0.7"
rodio = "0.20"
cpal = { version = "0.15", features = ["jack"] }

63
src/app.rs Normal file
View File

@@ -0,0 +1,63 @@
use color_eyre::eyre::Result;
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent};
use ratatui::{Terminal, prelude::CrosstermBackend};
use reqwest::Client;
use std::io::Stdout;
use crate::{
event::{Event, Events},
player::Player,
widgets::{Queue, QueueWidget},
};
pub struct App {
client: Client,
player: Player,
queue: Queue,
}
impl App {
pub fn new() -> Self {
Self {
player: Player::new(),
client: Client::default(),
queue: Queue::default(),
}
}
pub async fn run(
&mut self,
mut terminal: Terminal<CrosstermBackend<Stdout>>,
mut events: Events,
) -> Result<()> {
self.queue.request_random(self.client.clone());
loop {
if let Some(evt) = events.next().await {
match evt {
Event::Render => {
terminal.draw(|frame| {
frame.render_stateful_widget(
QueueWidget,
frame.area(),
&mut self.queue,
);
})?;
}
Event::Crossterm(CrosstermEvent::Key(KeyEvent { code, .. })) => match code {
KeyCode::Up => self.queue.up(),
KeyCode::Down => self.queue.down(),
KeyCode::Enter => self
.player
.play(self.queue.get_select_url(), self.client.clone()),
KeyCode::Char('q') => break,
_ => {}
},
Event::Quit => break,
_ => {}
}
};
}
Ok(())
}
}

67
src/event.rs Normal file
View File

@@ -0,0 +1,67 @@
use crossterm::event::{Event as CrosstermEvent, *};
use futures::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::{pin::Pin, time::Duration};
use tokio::time::interval;
use tokio_stream::{StreamMap, wrappers::IntervalStream};
pub struct Events {
streams: StreamMap<StreamName, Pin<Box<dyn Stream<Item = Event>>>>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
enum StreamName {
Ticks,
Render,
Crossterm,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
Tick,
Render,
Crossterm(CrosstermEvent),
}
impl Events {
pub fn new() -> Self {
Self {
streams: StreamMap::from_iter([
(StreamName::Ticks, tick_stream()),
(StreamName::Render, render_stream()),
(StreamName::Crossterm, crossterm_stream()),
]),
}
}
pub async fn next(&mut self) -> Option<Event> {
self.streams.next().await.map(|(_name, event)| event)
}
}
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let tick_delay = Duration::from_secs_f64(1.0);
let tick_interval = interval(tick_delay);
Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
}
fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let render_delay = Duration::from_secs_f64(1.0 / 15.);
let render_interval = interval(render_delay);
Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
}
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
Box::pin(EventStream::new().fuse().filter_map(|event| async move {
match event {
// Ignore key release / repeat events
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Release => None,
Ok(event) => Some(Event::Crossterm(event)),
Err(_) => Some(Event::Error),
}
}))
}

165
src/lib.rs Normal file
View File

@@ -0,0 +1,165 @@
use color_eyre::Result;
use cpal::traits::HostTrait;
use crossterm::event::{self, Event};
use futures::StreamExt;
use rand::{Rng, distr::Alphanumeric, rng};
use ratatui::{
DefaultTerminal, Frame,
style::Style,
text::{Line, Span},
widgets::{List, ListItem, ListState},
};
use std::{
env,
fs::File,
io::{BufReader, ErrorKind},
time::Duration,
};
use subsonic_types::{
common::Version,
request::{self, Authentication, Request, SubsonicRequest},
response::{Child, Response, ResponseBody},
};
use tokio::task::{JoinSet, spawn_blocking};
const SALT_SIZE: usize = 36;
async fn run(mut terminal: DefaultTerminal) -> Result<()> {
let res = test_subsonic().await?;
let song_list = if let ResponseBody::RandomSongs(songs) = res {
songs.song
} else {
return Err(std::io::Error::new(ErrorKind::Other, "h").into());
};
let items: Vec<_> = song_list
.iter()
.map(|song| {
ListItem::new(Line::from(vec![Span::styled(
song.title.clone(),
Style::default(),
)]))
})
.collect();
let list = List::new(items);
// This should be stored outside of the function in your application state.
let mut state = ListState::default();
*state.offset_mut() = 1; // display the second item and onwards
state.select(Some(0)); // select the forth item (0-indexed)
loop {
terminal.draw(|frame| {
frame.render_stateful_widget(&list, frame.area(), &mut state);
})?;
if matches!(event::read()?, Event::Key(_)) {
break Ok(());
}
}
}
pub async fn async_audio_dl() -> color_eyre::Result<()> {
let url = "https://music.domain.com/rest/download?u=jika&t=f61772aa3ecf7c4015df08735c9f26fd&s=0fea81&f=json&v=1.8.0&c=NavidromeUI&id=adc722f5dea2ea434c03c8c14f54865c&format=raw&bitrate=0";
let client = reqwest::Client::new();
let mut path = env::temp_dir();
path.push("ro.mp3");
let file = File::create(path)?;
let mut set = JoinSet::new();
let fut = async move {
let mut tmp_file = tokio::fs::File::from(file);
let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream();
while let Some(item) = byte_stream.next().await {
tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file)
.await
.unwrap();
}
};
set.spawn(fut);
let device = cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == cpal::HostId::Jack)
.expect(
"make sure --features jack is specified. only works on OSes where jack is available",
)).expect("jack host unavailable").default_output_device().unwrap();
let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap();
set.spawn(async {
let _ = test_audio(handle).await;
});
while let Some(res) = set.join_next().await {
res?;
}
Ok(())
}
pub async fn test_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> {
let sink = rodio::Sink::try_new(&handle).unwrap();
//let response = reqwest::blocking::get(url)?;
//sink.append(rodio::Decoder::new(Cursor::new(response.bytes()?))?);
//let file = std::fs::File::open("/home/jika/Downloads/Let It Be - Proket Remix.mp3").unwrap();
let file = std::fs::File::open("/tmp/ro.mp3").unwrap();
let a = loop {
if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) {
break o;
}
tokio::time::sleep(Duration::from_millis(100)).await;
};
spawn_blocking(move || {
sink.append(a);
sink.sleep_until_end();
})
.await?;
Ok(())
}
pub async fn test_subsonic() -> color_eyre::Result<ResponseBody> {
let base_url = "http://192.168.0.166:4533";
let salt: String = rng()
.sample_iter(Alphanumeric)
.take(SALT_SIZE)
.map(char::from)
.collect();
let pre_t = "password".to_string() + &salt;
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
let random = Request {
username: "bob".into(),
authentication: Authentication::Token { token, salt },
version: Version::LATEST,
client: "ping-example".into(),
format: None,
body: request::lists::GetRandomSongs {
size: Some(5),
genre: None,
to_year: None,
from_year: None,
music_folder_id: None,
},
};
let request_url = format!(
"{}{}?{}",
base_url,
request::lists::GetRandomSongs::PATH,
random.to_query()
);
let client = reqwest::Client::new();
let response_contents = client.get(&request_url).send().await?.text().await?;
let response = Response::from_xml(&response_contents)?;
Ok(response.body)
}

30
src/main.rs Normal file
View File

@@ -0,0 +1,30 @@
mod app;
mod event;
mod player;
mod widgets;
use color_eyre::eyre::Result;
use dotenv::dotenv;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
color_eyre::install()?;
dotenv().ok();
let terminal = ratatui::init();
set_panic_hook();
let events = event::Events::new();
app::App::new().run(terminal, events).await?;
ratatui::restore();
Ok(())
}
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
ratatui::restore();
hook(panic_info);
}));
}

76
src/player.rs Normal file
View File

@@ -0,0 +1,76 @@
use std::{env, fs::File, time::Duration};
use cpal::traits::HostTrait;
use futures::StreamExt;
use reqwest::Client;
use rodio::{OutputStream, OutputStreamHandle};
use std::io::BufReader;
use tokio::task::spawn_blocking;
pub struct Player {
handle: OutputStreamHandle,
_stream: OutputStream,
}
impl Player {
pub fn new() -> Self {
let device = cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == cpal::HostId::Jack)
.expect(
"make sure --features jack is specified. only works on OSes where jack is available",
)).expect("jack host unavailable").default_output_device().unwrap();
let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap();
Self { _stream, handle }
}
pub fn play(&self, url: String, client: Client) {
let client = client.clone();
tokio::spawn(async move { async_audio_dl(url, client).await });
let handle = self.handle.clone();
tokio::spawn(async move {
play_audio(handle).await;
});
}
}
async fn async_audio_dl(url: String, client: Client) -> color_eyre::Result<()> {
let mut path = env::temp_dir();
path.push("ro.mp3");
let file = File::create(path)?;
let mut tmp_file = tokio::fs::File::from(file);
let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream();
while let Some(item) = byte_stream.next().await {
tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file)
.await
.unwrap();
}
Ok(())
}
async fn play_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> {
let sink = rodio::Sink::try_new(&handle).unwrap();
let file = std::fs::File::open("/tmp/ro.mp3").unwrap();
let a = loop {
if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) {
break o;
}
tokio::time::sleep(Duration::from_millis(100)).await;
};
spawn_blocking(move || {
sink.append(a);
sink.sleep_until_end();
})
.await?;
Ok(())
}

3
src/widgets/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod queue;
pub use queue::*;

178
src/widgets/queue.rs Normal file
View File

@@ -0,0 +1,178 @@
use color_eyre::eyre::Result;
use itertools::Itertools;
use rand::{Rng, distr::Alphanumeric, rng};
use ratatui::{layout::Constraint::*, prelude::*, widgets::*};
use ratatui::{
prelude::{Buffer, Rect},
widgets::{StatefulWidget, Table, TableState},
};
use reqwest::Client;
use std::env;
use std::{
io::{ErrorKind, Stdout},
sync::{Arc, Mutex},
};
use subsonic_types::{
common::Version,
request::{self, Authentication, Request, SubsonicRequest},
response::{Child, Response, ResponseBody},
};
const SALT_SIZE: usize = 36;
#[derive(Debug, Default, Clone)]
pub struct Queue {
table_state: TableState,
songs_list: Arc<Mutex<Vec<Child>>>,
}
impl Queue {
pub fn request_random(&self, client: Client) {
let songs_list = self.songs_list.clone();
tokio::spawn(async move {
get_random(songs_list, client).await;
});
}
pub fn down(&mut self) {
self.table_state.select_next();
}
pub fn up(&mut self) {
self.table_state.select_previous();
}
pub fn get_select_url(&self) -> String {
let id = self.table_state.selected().unwrap();
let song = self.songs_list.lock().unwrap()[id].clone();
let base_url = get_base_url();
let salt: String = rng()
.sample_iter(Alphanumeric)
.take(SALT_SIZE)
.map(char::from)
.collect();
let pre_t = get_password() + &salt;
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
let download = Request {
username: get_username(),
authentication: Authentication::Token { token, salt },
version: Version::LATEST,
client: "ping-example".into(),
format: None,
body: request::retrieval::Download { id: song.id },
};
format!(
"{}{}?{}",
base_url,
request::retrieval::Download::PATH,
download.to_query()
)
}
}
fn get_base_url() -> String {
env::var("URL").expect("Failed to find 'URL' environment variable")
}
fn get_username() -> String {
env::var("USERNAME").expect("Failed to find 'USERNAME' environment variable")
}
fn get_password() -> String {
env::var("PASSWORD").expect("Failed to find 'PASSWORD' environment variable")
}
async fn get_random(songs_list: Arc<Mutex<Vec<Child>>>, client: Client) -> Result<()> {
let base_url = get_base_url();
let salt: String = rng()
.sample_iter(Alphanumeric)
.take(SALT_SIZE)
.map(char::from)
.collect();
let pre_t = get_password() + &salt;
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
let random = Request {
username: get_username(),
authentication: Authentication::Token { token, salt },
version: Version::LATEST,
client: "ping-example".into(),
format: None,
body: request::lists::GetRandomSongs {
size: Some(5),
genre: None,
to_year: None,
from_year: None,
music_folder_id: None,
},
};
let request_url = format!(
"{}{}?{}",
base_url,
request::lists::GetRandomSongs::PATH,
random.to_query()
);
let response_contents = client.get(&request_url).send().await?.text().await?;
let response = Response::from_xml(&response_contents)?;
let song_list = if let ResponseBody::RandomSongs(songs) = response.body {
songs.song
} else {
return Err(std::io::Error::new(ErrorKind::Other, "h").into());
};
let mut a = songs_list.lock().unwrap();
*a = song_list;
Ok(())
}
pub struct QueueWidget;
impl StatefulWidget for QueueWidget {
type State = Queue;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if !state.songs_list.lock().unwrap().is_empty() {
let vertical_pad = |line| Text::from(vec!["".into(), line, "".into()]);
let header_cells = ["Title", "Artist", "Duration"]
.map(|h| h.bold().into())
.map(vertical_pad);
let header = Row::new(header_cells).height(3);
let column_widths = [Max(20), Max(20), Max(6)];
let list = state.songs_list.lock().unwrap();
let rows = list
.iter()
.enumerate()
.map(|(index, song)| row_from_song(song))
.collect_vec();
let table = Table::new(rows, column_widths)
.header(header)
.column_spacing(3)
.highlight_spacing(HighlightSpacing::Always)
// The selected row, column, cell and its content can also be styled.
.row_highlight_style(Style::new().reversed())
.column_highlight_style(Style::new().red())
.cell_highlight_style(Style::new().blue())
// ...and potentially show a symbol in front of the selection.
.highlight_symbol(">>");
StatefulWidget::render(table, area, buf, &mut state.table_state);
}
}
}
fn row_from_song(song: &Child) -> Row {
let duration = song.duration.unwrap().to_duration().as_secs();
Row::new([
Text::from(song.title.clone()),
Text::from(song.artist.clone().unwrap()),
Text::from(format!("{:0>2}:{:0>2}", duration / 60, duration % 60)),
])
}