From cf0f408fb5d60d86c1141e859055f1decc3a1373 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 30 Jul 2026 19:28:23 +0200 Subject: Relative volume via prefixing/suffixing with +/- --- src/cli.rs | 4 +- src/daemon.rs | 181 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/cli.rs b/src/cli.rs index 87d9003..b1e6230 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,10 +54,10 @@ pub enum Commands { Next, /// Skip to previous track Previous, - /// Set volume (0-100) + /// Set volume (0-100), or +N/-N for relative change Volume { #[arg(value_name = "LEVEL")] - level: Option, + level: Option, }, /// Shuffle mode Shuffle { diff --git a/src/daemon.rs b/src/daemon.rs index 979c6b4..ddd3b77 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -10,6 +10,9 @@ use crate::config::Config; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; use crate::scrobbler::Scrobbler; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::error::Error; use std::io::{Read, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; @@ -550,24 +553,158 @@ fn parse_lrc_timestamp(ts: &str) -> Option { } } +#[derive(Deserialize)] +struct LrclibResponse { + #[serde(rename = "syncedLyrics", default)] + synced_lyrics: Option, + #[serde(rename = "plainLyrics", default)] + plain_lyrics: Option, +} + +fn url_encode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for &byte in s.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + b' ' => result.push_str("%20"), + _ => { + result.push_str(&format!("%{:02X}", byte)); + } + } + } + result +} + +fn fetch_lrclib_lyrics( + artist: &str, + title: &str, + album: Option<&str>, +) -> Result { + let client = Client::builder() + .user_agent("RSPlayer/0.9.2") + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP client error: {}", e))?; + + let mut url = format!( + "https://lrclib.net/api/get?artist_name={}&track_name={}", + url_encode(artist), + url_encode(title), + ); + if let Some(a) = album { + url.push_str(&format!("&album_name={}", url_encode(a))); + } + + let resp = client.get(&url).send().map_err(format_error_chain)?; + + let status = resp.status(); + if !status.is_success() { + if status.as_u16() == 404 { + return Err("Lyrics not found on LRClib".to_string()); + } + let body = resp.text().unwrap_or_default(); + return Err(format!("LRClib returned HTTP {}: {}", status, body)); + } + + resp.json::() + .map_err(|e| format!("Failed to parse LRClib response: {}", e)) +} + +/// Format a reqwest error and its full source chain into a single string. +fn format_error_chain(e: reqwest::Error) -> String { + let mut msg = format!("{}", e); + if let Some(url) = e.url() { + msg.push_str(&format!("\n url: {}", url)); + } + let mut source = e.source(); + while let Some(s) = source { + msg.push_str(&format!("\n caused by: {}", s)); + source = s.source(); + } + msg +} + fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { let track_path = match audio.current_track() { Some(p) => p, None => return, }; + let mut lrc_path = PathBuf::from(&track_path); - if !lrc_path.set_extension("lrc") || !lrc_path.exists() { + lrc_path.set_extension("lrc"); + + let mut txt_path = PathBuf::from(&track_path); + txt_path.set_extension("txt"); + + // Try local .lrc first (synced), then local .txt (plain), then LRClib + if lrc_path.exists() { + let content = match fs::read_to_string(&lrc_path) { + Ok(c) => c, + Err(_) => return, + }; + let lyrics = parse_lrc(&content); + if !lyrics.is_empty() { + return stream_synced_lyrics(stream, audio, &lyrics); + } + } + + if txt_path.exists() + && let Ok(content) = fs::read_to_string(&txt_path) + && !content.trim().is_empty() + { + let _ = stream.write_all(content.as_bytes()); + let _ = stream.write_all(b"\n"); return; } - let content = match fs::read_to_string(&lrc_path) { - Ok(c) => c, - Err(_) => return, - }; - let lyrics = parse_lrc(&content); - if lyrics.is_empty() { + + // No local lyrics — fetch from LRClib + let track = Track::from_path(track_path.clone()); + let artist = track.artist.as_deref().unwrap_or(""); + let title = track.title.as_deref().unwrap_or(""); + + if artist.is_empty() && title.is_empty() { + let _ = stream.write_all(b"No lyrics available and no metadata to query LRClib\n"); return; } + let album = track.album.as_deref(); + + match fetch_lrclib_lyrics(artist, title, album) { + Ok(lyrics) => { + if let Some(ref synced) = lyrics.synced_lyrics + && !synced.is_empty() + { + let _ = fs::write(&lrc_path, synced); + let content = match fs::read_to_string(&lrc_path) { + Ok(c) => c, + Err(_) => return, + }; + let parsed = parse_lrc(&content); + if !parsed.is_empty() { + stream_synced_lyrics(stream, audio, &parsed) + } + } else if let Some(ref plain) = lyrics.plain_lyrics + && !plain.is_empty() + { + let _ = fs::write(&txt_path, plain); + let _ = stream.write_all(plain.as_bytes()); + let _ = stream.write_all(b"\n"); + } else { + let _ = stream.write_all(b"No lyrics found on LRClib\n"); + } + } + Err(e) => { + let msg = format!("Failed to fetch lyrics: {}\n", e); + let _ = stream.write_all(msg.as_bytes()); + } + } +} + +/// Stream synced (LRC) lyrics in real time, tracking audio playback position. +fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[LrcLine]) { let mut last_idx = None; let mut prev_line_count = 0; loop { @@ -1330,16 +1467,40 @@ fn process_command( format!("Volume: {}\n", vol) } _ if cmd.starts_with("volume ") => { - if let Ok(level) = cmd[7..].trim().parse::() { + let arg = cmd[7..].trim(); + // Try relative: +N, -N, N+, N- + let rel_parse = arg + .strip_prefix('+') + .or_else(|| arg.strip_prefix('-')) + .map(|n| (n, arg.starts_with('+'))) + .or_else(|| { + arg.rfind(['+', '-']) + .filter(|&i| i + 1 == arg.len()) + .map(|i| (&arg[..i], arg.as_bytes()[i] == b'+')) + }); + if let Some((rest, is_plus)) = rel_parse + && let Ok(delta) = rest.parse::() + { + let sign = if is_plus { 1i8 } else { -1i8 }; + let current = audio.get_volume() as i8; + let new_level = (current + sign * delta).clamp(0, 100) as u8; + audio.set_volume(new_level as f32 / 100.0); + if let Ok(mut config) = Config::load() { + config.volume = Some(new_level); + let _ = config.save(); + } + return format!("Volume set to {}\n", new_level); + } + // Try absolute value + if let Ok(level) = arg.parse::() { audio.set_volume(level as f32 / 100.0); - // Save to config if let Ok(mut config) = Config::load() { config.volume = Some(level); let _ = config.save(); } format!("Volume set to {}\n", level) } else { - "Usage: volume <0-100>\n".to_string() + "Usage: volume <0-100>, +N, -N, N+, N-\n".to_string() } } _ if cmd.starts_with("queue") => { -- cgit v1.2.3