aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-30 19:28:23 +0200
committersalmonik <salmonik@tuta.io>2026-07-30 19:28:23 +0200
commitcf0f408fb5d60d86c1141e859055f1decc3a1373 (patch)
tree00ab615d930c381cf921fdad81fa16f9c03e7f0a
parentFix synchronous HTTP calls (diff)
downloadrsplayer-cf0f408fb5d60d86c1141e859055f1decc3a1373.tar.gz
rsplayer-cf0f408fb5d60d86c1141e859055f1decc3a1373.zip
Relative volume via prefixing/suffixing with +/-
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--src/cli.rs4
-rw-r--r--src/daemon.rs181
4 files changed, 175 insertions, 14 deletions
diff --git a/Cargo.lock b/Cargo.lock
index acf5ce5..9c69f54 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
4 4
5[[package]] 5[[package]]
6name = "RSPlayer" 6name = "RSPlayer"
7version = "0.9.1" 7version = "0.9.3"
8dependencies = [ 8dependencies = [
9 "clap", 9 "clap",
10 "clap_complete", 10 "clap_complete",
diff --git a/Cargo.toml b/Cargo.toml
index 42e0442..e3567d7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "RSPlayer" 2name = "RSPlayer"
3version = "0.9.2" 3version = "0.9.3"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "GPL-3.0 or later"
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 {
54 Next, 54 Next,
55 /// Skip to previous track 55 /// Skip to previous track
56 Previous, 56 Previous,
57 /// Set volume (0-100) 57 /// Set volume (0-100), or +N/-N for relative change
58 Volume { 58 Volume {
59 #[arg(value_name = "LEVEL")] 59 #[arg(value_name = "LEVEL")]
60 level: Option<u8>, 60 level: Option<String>,
61 }, 61 },
62 /// Shuffle mode 62 /// Shuffle mode
63 Shuffle { 63 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;
10use crate::notification; 10use crate::notification;
11use crate::queue::{Queue, ShuffleMode, Track}; 11use crate::queue::{Queue, ShuffleMode, Track};
12use crate::scrobbler::Scrobbler; 12use crate::scrobbler::Scrobbler;
13use reqwest::blocking::Client;
14use serde::Deserialize;
15use std::error::Error;
13use std::io::{Read, Write}; 16use std::io::{Read, Write};
14use std::os::unix::net::{UnixListener, UnixStream}; 17use std::os::unix::net::{UnixListener, UnixStream};
15use std::path::{Path, PathBuf}; 18use std::path::{Path, PathBuf};
@@ -550,24 +553,158 @@ fn parse_lrc_timestamp(ts: &str) -> Option<f64> {
550 } 553 }
551} 554}
552 555
556#[derive(Deserialize)]
557struct LrclibResponse {
558 #[serde(rename = "syncedLyrics", default)]
559 synced_lyrics: Option<String>,
560 #[serde(rename = "plainLyrics", default)]
561 plain_lyrics: Option<String>,
562}
563
564fn url_encode(s: &str) -> String {
565 let mut result = String::with_capacity(s.len());
566 for &byte in s.as_bytes() {
567 match byte {
568 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
569 result.push(byte as char);
570 }
571 b' ' => result.push_str("%20"),
572 _ => {
573 result.push_str(&format!("%{:02X}", byte));
574 }
575 }
576 }
577 result
578}
579
580fn fetch_lrclib_lyrics(
581 artist: &str,
582 title: &str,
583 album: Option<&str>,
584) -> Result<LrclibResponse, String> {
585 let client = Client::builder()
586 .user_agent("RSPlayer/0.9.2")
587 .connect_timeout(Duration::from_secs(10))
588 .timeout(Duration::from_secs(30))
589 .build()
590 .map_err(|e| format!("HTTP client error: {}", e))?;
591
592 let mut url = format!(
593 "https://lrclib.net/api/get?artist_name={}&track_name={}",
594 url_encode(artist),
595 url_encode(title),
596 );
597 if let Some(a) = album {
598 url.push_str(&format!("&album_name={}", url_encode(a)));
599 }
600
601 let resp = client.get(&url).send().map_err(format_error_chain)?;
602
603 let status = resp.status();
604 if !status.is_success() {
605 if status.as_u16() == 404 {
606 return Err("Lyrics not found on LRClib".to_string());
607 }
608 let body = resp.text().unwrap_or_default();
609 return Err(format!("LRClib returned HTTP {}: {}", status, body));
610 }
611
612 resp.json::<LrclibResponse>()
613 .map_err(|e| format!("Failed to parse LRClib response: {}", e))
614}
615
616/// Format a reqwest error and its full source chain into a single string.
617fn format_error_chain(e: reqwest::Error) -> String {
618 let mut msg = format!("{}", e);
619 if let Some(url) = e.url() {
620 msg.push_str(&format!("\n url: {}", url));
621 }
622 let mut source = e.source();
623 while let Some(s) = source {
624 msg.push_str(&format!("\n caused by: {}", s));
625 source = s.source();
626 }
627 msg
628}
629
553fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) { 630fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) {
554 let track_path = match audio.current_track() { 631 let track_path = match audio.current_track() {
555 Some(p) => p, 632 Some(p) => p,
556 None => return, 633 None => return,
557 }; 634 };
635
558 let mut lrc_path = PathBuf::from(&track_path); 636 let mut lrc_path = PathBuf::from(&track_path);
559 if !lrc_path.set_extension("lrc") || !lrc_path.exists() { 637 lrc_path.set_extension("lrc");
638
639 let mut txt_path = PathBuf::from(&track_path);
640 txt_path.set_extension("txt");
641
642 // Try local .lrc first (synced), then local .txt (plain), then LRClib
643 if lrc_path.exists() {
644 let content = match fs::read_to_string(&lrc_path) {
645 Ok(c) => c,
646 Err(_) => return,
647 };
648 let lyrics = parse_lrc(&content);
649 if !lyrics.is_empty() {
650 return stream_synced_lyrics(stream, audio, &lyrics);
651 }
652 }
653
654 if txt_path.exists()
655 && let Ok(content) = fs::read_to_string(&txt_path)
656 && !content.trim().is_empty()
657 {
658 let _ = stream.write_all(content.as_bytes());
659 let _ = stream.write_all(b"\n");
560 return; 660 return;
561 } 661 }
562 let content = match fs::read_to_string(&lrc_path) { 662
563 Ok(c) => c, 663 // No local lyrics — fetch from LRClib
564 Err(_) => return, 664 let track = Track::from_path(track_path.clone());
565 }; 665 let artist = track.artist.as_deref().unwrap_or("");
566 let lyrics = parse_lrc(&content); 666 let title = track.title.as_deref().unwrap_or("");
567 if lyrics.is_empty() { 667
668 if artist.is_empty() && title.is_empty() {
669 let _ = stream.write_all(b"No lyrics available and no metadata to query LRClib\n");
568 return; 670 return;
569 } 671 }
570 672
673 let album = track.album.as_deref();
674
675 match fetch_lrclib_lyrics(artist, title, album) {
676 Ok(lyrics) => {
677 if let Some(ref synced) = lyrics.synced_lyrics
678 && !synced.is_empty()
679 {
680 let _ = fs::write(&lrc_path, synced);
681 let content = match fs::read_to_string(&lrc_path) {
682 Ok(c) => c,
683 Err(_) => return,
684 };
685 let parsed = parse_lrc(&content);
686 if !parsed.is_empty() {
687 stream_synced_lyrics(stream, audio, &parsed)
688 }
689 } else if let Some(ref plain) = lyrics.plain_lyrics
690 && !plain.is_empty()
691 {
692 let _ = fs::write(&txt_path, plain);
693 let _ = stream.write_all(plain.as_bytes());
694 let _ = stream.write_all(b"\n");
695 } else {
696 let _ = stream.write_all(b"No lyrics found on LRClib\n");
697 }
698 }
699 Err(e) => {
700 let msg = format!("Failed to fetch lyrics: {}\n", e);
701 let _ = stream.write_all(msg.as_bytes());
702 }
703 }
704}
705
706/// Stream synced (LRC) lyrics in real time, tracking audio playback position.
707fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[LrcLine]) {
571 let mut last_idx = None; 708 let mut last_idx = None;
572 let mut prev_line_count = 0; 709 let mut prev_line_count = 0;
573 loop { 710 loop {
@@ -1330,16 +1467,40 @@ fn process_command(
1330 format!("Volume: {}\n", vol) 1467 format!("Volume: {}\n", vol)
1331 } 1468 }
1332 _ if cmd.starts_with("volume ") => { 1469 _ if cmd.starts_with("volume ") => {
1333 if let Ok(level) = cmd[7..].trim().parse::<u8>() { 1470 let arg = cmd[7..].trim();
1471 // Try relative: +N, -N, N+, N-
1472 let rel_parse = arg
1473 .strip_prefix('+')
1474 .or_else(|| arg.strip_prefix('-'))
1475 .map(|n| (n, arg.starts_with('+')))
1476 .or_else(|| {
1477 arg.rfind(['+', '-'])
1478 .filter(|&i| i + 1 == arg.len())
1479 .map(|i| (&arg[..i], arg.as_bytes()[i] == b'+'))
1480 });
1481 if let Some((rest, is_plus)) = rel_parse
1482 && let Ok(delta) = rest.parse::<i8>()
1483 {
1484 let sign = if is_plus { 1i8 } else { -1i8 };
1485 let current = audio.get_volume() as i8;
1486 let new_level = (current + sign * delta).clamp(0, 100) as u8;
1487 audio.set_volume(new_level as f32 / 100.0);
1488 if let Ok(mut config) = Config::load() {
1489 config.volume = Some(new_level);
1490 let _ = config.save();
1491 }
1492 return format!("Volume set to {}\n", new_level);
1493 }
1494 // Try absolute value
1495 if let Ok(level) = arg.parse::<u8>() {
1334 audio.set_volume(level as f32 / 100.0); 1496 audio.set_volume(level as f32 / 100.0);
1335 // Save to config
1336 if let Ok(mut config) = Config::load() { 1497 if let Ok(mut config) = Config::load() {
1337 config.volume = Some(level); 1498 config.volume = Some(level);
1338 let _ = config.save(); 1499 let _ = config.save();
1339 } 1500 }
1340 format!("Volume set to {}\n", level) 1501 format!("Volume set to {}\n", level)
1341 } else { 1502 } else {
1342 "Usage: volume <0-100>\n".to_string() 1503 "Usage: volume <0-100>, +N, -N, N+, N-\n".to_string()
1343 } 1504 }
1344 } 1505 }
1345 _ if cmd.starts_with("queue") => { 1506 _ if cmd.starts_with("queue") => {