mod app;
mod config;
mod input;
mod models;
mod ui;
use std::{
fs,
io::{Write, stdout},
os::unix::net::UnixStream,
process::{Command, Stdio},
time::{Duration, Instant},
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures_util::{SinkExt, StreamExt};
use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui_image::picker::Picker;
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt},
sync::{mpsc, watch},
};
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use crate::{
app::{AppEvent, AppMode, AppState, REFRESH_WINDOW},
input::handle_input,
models::{ApiInfo, SongData, WsPayload, parse_mpv_media_title},
ui::draw_ui,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if Command::new("mpv").arg("--version").output().is_err() {
eprintln!("error: missing required dependency 'mpv'. please install it using your system package manager.");
std::process::exit(1);
}
let mut state = AppState::new();
let is_config_error = matches!(state.mode, AppMode::ConfigError(_));
let socket_path = state.config.system.socket_path.clone();
let stream_url = state.config.player.stream_url.clone();
let volume_arg = format!("--volume={}", state.volume);
let mut mpv_process = None;
if !is_config_error {
let _ = fs::remove_file(&socket_path);
let mut mpv_args = vec![
"--no-video".to_string(),
"--really-quiet".to_string(),
"--no-terminal".to_string(),
"--user-agent=curl/8.4.0".to_string(),
volume_arg,
format!("--input-ipc-server={socket_path}"),
];
if state.config.player.loudness_normalization {
mpv_args.push("--af=loudnorm=I=-16:TP=-1.5:LRA=11".to_string());
}
mpv_args.push(stream_url);
mpv_process = Command::new("mpv")
.args(mpv_args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok();
}
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let (tx, mut rx) = mpsc::channel(32);
let mut controls = None;
if !is_config_error && state.config.system.mpris_enabled {
controls =
MediaControls::new(PlatformConfig { dbus_name: "grrs", display_name: "Gensokyo Radio TUI", hwnd: None })
.ok();
}
if let Some(ref mut c) = controls {
let tx_media = tx.clone();
c.attach(move |event| {
let _ = tx_media.blocking_send(AppEvent::MediaControl(event));
})
.ok();
c.set_playback(MediaPlayback::Playing { progress: None }).ok();
}
let picker = Picker::from_query_stdio().expect("failed to query terminal for image support");
let loop_picker = picker.clone();
let http_client = reqwest::Client::builder()
.user_agent("curl/8.4.0")
.timeout(Duration::from_secs(30))
.build()
.expect("failed to build http client");
let tx_input = tx.clone();
std::thread::spawn(move || {
loop {
if event::poll(Duration::from_millis(250)).unwrap_or(false)
&& let Ok(evt) = event::read()
&& matches!(evt, CEvent::Key(_) | CEvent::Mouse(_) | CEvent::Resize(_, _))
&& tx_input.blocking_send(AppEvent::Input(evt)).is_err()
{
break;
}
if tx_input.blocking_send(AppEvent::Tick).is_err() {
break;
}
}
});
let (art_tx, mut art_rx) =
watch::channel::<(i64, String, String, String)>((0, String::new(), String::new(), String::new()));
let (ws_nudge_tx, mut ws_nudge_rx) = watch::channel(());
if !is_config_error {
let tx_api = tx.clone();
let api_client = http_client.clone();
tokio::spawn(async move {
loop {
if let Ok(resp) = api_client.get("https://gensokyoradio.net/api/station/playing/").send().await
&& let Ok(text) = resp.text().await
&& let Ok(info) = serde_json::from_str::<ApiInfo>(&text)
{
let _ = tx_api
.send(AppEvent::ApiUpdate {
listeners: info.server_info.listeners,
circle_link: info.misc.circle_link,
rating: info.song_data.rating,
times_rated: info.song_data.times_rated,
})
.await;
}
tokio::time::sleep(Duration::from_secs(15)).await;
}
});
let tx_ws = tx.clone();
let art_tx_ws = art_tx.clone();
tokio::spawn(async move {
loop {
if let Ok((mut ws_stream, _)) = connect_async("wss://gensokyoradio.net/wss").await {
if tx_ws.send(AppEvent::ConnectionStatus(String::new())).await.is_err() {
return;
}
let _ = ws_stream
.send(Message::Text(serde_json::json!({"message": "grInitialConnection"}).to_string().into()))
.await;
loop {
tokio::select! {
msg = ws_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(payload) = serde_json::from_str::<WsPayload>(&text)
&& payload.songid != 0 {
let year = payload.year.and_then(|y| if y.is_number() { y.as_i64().map(|n| n.to_string()) } else { y.as_str().map(std::string::ToString::to_string) }).unwrap_or_default();
let played = payload.played.and_then(|p| if p.is_number() { p.as_u64() } else if let Some(s) = p.as_str() { s.parse().ok() } else { None }).unwrap_or(0);
let song = SongData {
id: payload.songid,
title: payload.title.unwrap_or_else(|| "Unknown".to_string()),
artist: payload.artist.unwrap_or_else(|| "Unknown".to_string()),
album: payload.album.unwrap_or_else(|| "Unknown".to_string()),
circle: payload.circle.unwrap_or_default(),
circle_link: String::new(),
vocal: payload.vocal.unwrap_or_default(),
original: payload.original.unwrap_or_default(),
year,
duration: payload.duration.unwrap_or(0),
played,
art_url: payload.albumart.clone().unwrap_or_default(),
listeners: 0,
rating: String::new(),
times_rated: 0,
};
let _ = art_tx_ws.send((song.id, song.art_url.clone(), song.title.clone(), song.artist.clone()));
if tx_ws.send(AppEvent::SongUpdate(song)).await.is_err() { return; }
}
}
Some(_) => {}
None => break,
}
}
res = ws_nudge_rx.changed() => {
if res.is_err() { return; }
break;
}
}
}
} else {
if tx_ws
.send(AppEvent::ConnectionStatus("websocket dropped. reconnecting...".to_string()))
.await
.is_err()
{
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
});
let tx_art = tx.clone();
let art_picker = picker.clone();
let art_client = http_client.clone();
tokio::spawn(async move {
loop {
if art_rx.changed().await.is_err() {
return;
}
let mut current = art_rx.borrow().clone();
while current.0 != 0 && !current.1.is_empty() {
let url = if current.1.starts_with("http") {
current.1.clone()
} else {
format!("https://gensokyoradio.net{}", current.1)
};
let mut ok = false;
for _ in 0..3 {
if let Ok(resp) = art_client.get(&url).send().await
&& let Ok(bytes) = resp.bytes().await
{
let b = bytes.to_vec();
let picker_clone = art_picker.clone();
let tx_art_local = tx_art.clone();
let decode_result = tokio::task::spawn_blocking(move || {
if let Ok(img) = image::load_from_memory(&b) {
let protocol = picker_clone.new_resize_protocol(img);
return Some((protocol, b));
}
None
})
.await;
if let Ok(Some((protocol, original_bytes))) = decode_result {
let _ = tx_art_local.send(AppEvent::ArtUpdate(protocol, original_bytes)).await;
ok = true;
break;
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
let _ = tx_art.send(AppEvent::KosuzuBusy(!ok)).await;
if ok {
break;
}
tokio::time::sleep(Duration::from_secs(3)).await;
if art_rx.has_changed().unwrap_or(false) {
current = art_rx.borrow().clone();
}
}
}
});
let tx_patchouli = tx.clone();
let patchouli_socket = socket_path.clone();
tokio::spawn(async move {
loop {
if let Ok(mut stream) = tokio::net::UnixStream::connect(&patchouli_socket).await {
let cmd = "{\"command\": [\"observe_property\", 1, \"media-title\"]}\n";
if stream.write_all(cmd.as_bytes()).await.is_ok() {
let mut reader = tokio::io::BufReader::new(stream);
let mut line = String::new();
while let Ok(bytes) = reader.read_line(&mut line).await {
if bytes == 0 {
break;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
&& value.get("event").and_then(|v| v.as_str()) == Some("property-change")
&& let Some(title) = value.get("data").and_then(|v| v.as_str())
&& let Some(mpv_song) = parse_mpv_media_title(title)
{
let _ = tx_patchouli.send(AppEvent::MpvUpdate(mpv_song)).await;
}
line.clear();
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
});
}
let mut last_nudge = Instant::now().checked_sub(Duration::from_secs(10)).unwrap_or_else(Instant::now);
let mut hide_art = false;
let mut mpv_respawn_time: Option<Instant> = None;
loop {
terminal.draw(|f| draw_ui(f, &mut state, hide_art))?;
if let Some(event) = rx.recv().await {
match event {
AppEvent::Input(evt) => {
let outcome =
handle_input(evt, &mut state, &tx, &picker, &loop_picker, &socket_path, &http_client).await;
if outcome.should_quit {
break;
}
if outcome.should_clear {
hide_art = true;
let _ = terminal.draw(|f| draw_ui(f, &mut state, hide_art));
hide_art = false;
}
},
AppEvent::MediaControl(m_event) => {
if matches!(m_event, MediaControlEvent::Toggle | MediaControlEvent::Play | MediaControlEvent::Pause)
{
state.is_muted = !state.is_muted;
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n");
}
} else if matches!(m_event, MediaControlEvent::Quit) {
break;
}
},
AppEvent::SongUpdate(song) => {
let cached_listeners = state.song.listeners;
if song.id != state.song.id {
state.art_protocol = None;
state.song_start_time =
Instant::now().checked_sub(Duration::from_secs(song.played)).unwrap_or_else(Instant::now);
state.song.rating = String::new();
state.song.times_rated = 0;
state.song.circle_link = String::new();
let tx_api_quick = tx.clone();
let quick_client = http_client.clone();
tokio::spawn(async move {
if let Ok(resp) =
quick_client.get("https://gensokyoradio.net/api/station/playing/").send().await
&& let Ok(text) = resp.text().await
&& let Ok(info) = serde_json::from_str::<ApiInfo>(&text)
{
let _ = tx_api_quick
.send(AppEvent::ApiUpdate {
listeners: info.server_info.listeners,
circle_link: info.misc.circle_link,
rating: info.song_data.rating,
times_rated: info.song_data.times_rated,
})
.await;
}
});
}
state.song = song;
state.song.listeners = cached_listeners;
state.status_message = String::new();
state.refresh_until = None;
if state.config.system.notifications
&& state.song.id != 0
&& state.notified_song_id != state.song.id
&& state.song.art_url.is_empty()
{
state.notified_song_id = state.song.id;
let title = state.song.title.clone();
let artist = state.song.artist.clone();
tokio::task::spawn_blocking(move || {
let mut notif = notify_rust::Notification::new();
notif.summary(&title).body(&artist).appname("grrs");
let _ = notif.show();
});
}
if let Some(ref mut c) = controls {
let art_path = state
.likes_file
.parent()
.expect("likes_file has no parent directory")
.join("current_art.jpg");
let art_url = format!("file://{}", art_path.to_str().unwrap_or(""));
c.set_metadata(MediaMetadata {
title: Some(state.song.title.as_str()),
artist: Some(state.song.artist.as_str()),
album: Some(state.song.album.as_str()),
duration: Some(Duration::from_secs(state.song.duration)),
cover_url: Some(art_url.as_str()),
..Default::default()
})
.ok();
}
},
AppEvent::ApiUpdate { listeners, circle_link, rating, times_rated } => {
state.song.listeners = listeners;
state.song.circle_link = circle_link;
state.song.rating = rating;
state.song.times_rated = times_rated;
},
AppEvent::ArtUpdate(protocol, bytes) => {
state.live_art_bytes = Some(bytes.clone());
state.art_protocol = Some(protocol);
let path =
state.likes_file.parent().expect("likes_file has no parent directory").join("current_art.jpg");
let title = state.song.title.clone();
let artist = state.song.artist.clone();
let send_notif = state.config.system.notifications && state.notified_song_id != state.song.id;
if send_notif {
state.notified_song_id = state.song.id;
}
tokio::spawn(async move {
let _ = tokio::fs::write(&path, bytes).await;
if send_notif {
let _ = tokio::task::spawn_blocking(move || {
let mut notif = notify_rust::Notification::new();
notif.summary(&title).body(&artist).appname("grrs");
notif.icon(path.to_str().unwrap_or(""));
let _ = notif.show();
})
.await;
}
});
},
AppEvent::LocalArtLoaded(id, protocol) => {
if let Some(idx) = state.liked_list_state.selected() {
let sorted_likes = &state.filtered_likes;
if let Some(song) = sorted_likes.get(idx)
&& song.id == id
{
state.selected_art = protocol;
}
}
},
AppEvent::ConnectionStatus(msg) => state.status_message = msg,
AppEvent::MpvUpdate(mpv) => {
let changed = state.mpv_song.title != mpv.title || state.mpv_song.artist != mpv.artist;
state.mpv_song = mpv;
let elapsed = state.song_start_time.elapsed().as_secs();
let expired = state.song.duration > 0 && elapsed >= state.song.duration;
if changed || !state.matches_mpv() || expired {
state.refresh_until = Some(Instant::now() + REFRESH_WINDOW);
if last_nudge.elapsed() > Duration::from_secs(2) {
state.art_protocol = None;
let _ = ws_nudge_tx.send(());
last_nudge = Instant::now();
}
}
},
AppEvent::KosuzuBusy(busy) => {
if busy {
state.refresh_until = Some(Instant::now() + REFRESH_WINDOW);
if state.config.system.notifications && state.notified_song_id != state.song.id {
state.notified_song_id = state.song.id;
let title = state.song.title.clone();
let artist = state.song.artist.clone();
tokio::task::spawn_blocking(move || {
let mut notif = notify_rust::Notification::new();
notif.summary(&title).body(&artist).appname("grrs");
let _ = notif.show();
});
}
} else {
state.refresh_until = None;
}
},
AppEvent::Tick => {
if let Some(ref mut child) = mpv_process {
if let Ok(Some(_status)) = child.try_wait() {
mpv_process = None;
if state.mpv_retries > 0 {
state.mpv_retries -= 1;
state.status_message =
format!("stream dropped. reconnecting... ({} left)", state.mpv_retries);
state.status_message_time = Some(Instant::now());
mpv_respawn_time = Some(Instant::now() + Duration::from_secs(5));
} else {
state.status_message = "stream failed. max retries reached.".to_string();
state.status_message_time = Some(Instant::now());
}
}
} else if let Some(respawn_time) = mpv_respawn_time {
if Instant::now() >= respawn_time {
mpv_respawn_time = None;
let _ = fs::remove_file(&state.config.system.socket_path);
let mut args = vec![
"--no-video".to_string(),
"--really-quiet".to_string(),
"--no-terminal".to_string(),
"--user-agent=curl/8.4.0".to_string(),
format!("--volume={}", state.volume),
format!("--input-ipc-server={}", state.config.system.socket_path),
];
if state.config.player.loudness_normalization {
args.push("--af=loudnorm=I=-16:TP=-1.5:LRA=11".to_string());
}
args.push(state.config.player.stream_url.clone());
mpv_process = Command::new("mpv")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok();
}
}
if let Some(t) = state.status_message_time
&& t.elapsed().as_secs() >= 3
{
state.status_message.clear();
state.status_message_time = None;
}
if let Some(t) = state.volume_time
&& t.elapsed().as_secs() >= 3
{
state.volume_time = None;
}
let mut clear_for_nav = false;
if let Some(t) = state.nav_time
&& t.elapsed().as_secs() >= 3
{
state.nav_time = None;
clear_for_nav = true;
}
if clear_for_nav {
hide_art = true;
let _ = terminal.draw(|f| draw_ui(f, &mut state, hide_art));
hide_art = false;
}
},
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
if let Some(mut child) = mpv_process {
let _ = child.kill();
let _ = child.wait();
}
if !is_config_error {
let _ = fs::remove_file(&socket_path);
}
Ok(())
}