diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-30 19:28:23 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-30 19:28:23 +0200 |
| commit | cf0f408fb5d60d86c1141e859055f1decc3a1373 (patch) | |
| tree | 00ab615d930c381cf921fdad81fa16f9c03e7f0a | |
| parent | Fix synchronous HTTP calls (diff) | |
| download | rsplayer-cf0f408fb5d60d86c1141e859055f1decc3a1373.tar.gz rsplayer-cf0f408fb5d60d86c1141e859055f1decc3a1373.zip | |
Relative volume via prefixing/suffixing with +/-
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/cli.rs | 4 | ||||
| -rw-r--r-- | src/daemon.rs | 181 |
4 files changed, 175 insertions, 14 deletions
| @@ -4,7 +4,7 @@ version = 4 | |||
| 4 | 4 | ||
| 5 | [[package]] | 5 | [[package]] |
| 6 | name = "RSPlayer" | 6 | name = "RSPlayer" |
| 7 | version = "0.9.1" | 7 | version = "0.9.3" |
| 8 | dependencies = [ | 8 | dependencies = [ |
| 9 | "clap", | 9 | "clap", |
| 10 | "clap_complete", | 10 | "clap_complete", |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "RSPlayer" | 2 | name = "RSPlayer" |
| 3 | version = "0.9.2" | 3 | version = "0.9.3" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | description = "Rust music player daemon with Unix socket IPC" | 5 | description = "Rust music player daemon with Unix socket IPC" |
| 6 | license = "GPL-3.0 or later" | 6 | license = "GPL-3.0 or later" |
| @@ -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; | |||
| 10 | use crate::notification; | 10 | use crate::notification; |
| 11 | use crate::queue::{Queue, ShuffleMode, Track}; | 11 | use crate::queue::{Queue, ShuffleMode, Track}; |
| 12 | use crate::scrobbler::Scrobbler; | 12 | use crate::scrobbler::Scrobbler; |
| 13 | use reqwest::blocking::Client; | ||
| 14 | use serde::Deserialize; | ||
| 15 | use std::error::Error; | ||
| 13 | use std::io::{Read, Write}; | 16 | use std::io::{Read, Write}; |
| 14 | use std::os::unix::net::{UnixListener, UnixStream}; | 17 | use std::os::unix::net::{UnixListener, UnixStream}; |
| 15 | use std::path::{Path, PathBuf}; | 18 | use 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)] | ||
| 557 | struct LrclibResponse { | ||
| 558 | #[serde(rename = "syncedLyrics", default)] | ||
| 559 | synced_lyrics: Option<String>, | ||
| 560 | #[serde(rename = "plainLyrics", default)] | ||
| 561 | plain_lyrics: Option<String>, | ||
| 562 | } | ||
| 563 | |||
| 564 | fn 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 | |||
| 580 | fn 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. | ||
| 617 | fn 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 | |||
| 553 | fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) { | 630 | fn 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. | ||
| 707 | fn 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") => { |
