aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-27 20:46:33 +0200
committersalmonik <raisersoft@proton.me>2026-05-27 20:46:33 +0200
commit0ae9d89f18bf36407229798a3b4943604fb708e4 (patch)
tree47fbc6ad1d22ddd575f9773d5e1d36dae7bd0815
parentplay [track] [-a/--album] option (diff)
downloadrsplayer-0ae9d89f18bf36407229798a3b4943604fb708e4.tar.gz
rsplayer-0ae9d89f18bf36407229798a3b4943604fb708e4.zip
Add internet streams support
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--src/audio.rs131
-rw-r--r--src/daemon.rs216
-rw-r--r--src/queue.rs30
-rw-r--r--tests/queue.rs2
6 files changed, 310 insertions, 73 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d6bef42..f58156d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1948,7 +1948,7 @@ dependencies = [
1948 1948
1949[[package]] 1949[[package]]
1950name = "rsp" 1950name = "rsp"
1951version = "0.8.2" 1951version = "0.8.3"
1952dependencies = [ 1952dependencies = [
1953 "clap", 1953 "clap",
1954 "clap_complete", 1954 "clap_complete",
diff --git a/Cargo.toml b/Cargo.toml
index 49929c0..ef61127 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "rsp" 2name = "rsp"
3version = "0.8.2" 3version = "0.8.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/audio.rs b/src/audio.rs
index e3d88bc..6da47c3 100644
--- a/src/audio.rs
+++ b/src/audio.rs
@@ -6,12 +6,107 @@
6*/ 6*/
7 7
8use crate::notification; 8use crate::notification;
9use std::path::PathBuf; 9use std::io::{Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, Ordering}; 11use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::mpsc::{self, Receiver, Sender}; 12use std::sync::mpsc::{self, Receiver, Sender};
12use std::sync::{Arc, Mutex}; 13use std::sync::{Arc, Mutex};
13use std::thread; 14use std::thread;
14 15
16struct HttpMediaSource {
17 reader: Mutex<Box<dyn Read + Send>>,
18 buffer: Vec<u8>,
19 position: usize,
20 eof: bool,
21}
22
23impl HttpMediaSource {
24 fn new(reader: Box<dyn Read + Send>) -> Self {
25 HttpMediaSource {
26 reader: Mutex::new(reader),
27 buffer: Vec::new(),
28 position: 0,
29 eof: false,
30 }
31 }
32}
33
34impl Read for HttpMediaSource {
35 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
36 if self.position < self.buffer.len() {
37 let available = self.buffer.len() - self.position;
38 let to_copy = buf.len().min(available);
39 buf[..to_copy].copy_from_slice(&self.buffer[self.position..self.position + to_copy]);
40 self.position += to_copy;
41 return Ok(to_copy);
42 }
43 if self.eof {
44 return Ok(0);
45 }
46 let n = self.reader.lock().unwrap().read(buf)?;
47 if n > 0 {
48 self.buffer.extend_from_slice(&buf[..n]);
49 self.position += n;
50 } else {
51 self.eof = true;
52 }
53 Ok(n)
54 }
55}
56
57impl Seek for HttpMediaSource {
58 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
59 match pos {
60 SeekFrom::Start(offset) => {
61 let offset = offset as usize;
62 while offset > self.buffer.len() && !self.eof {
63 let mut temp = [0u8; 8192];
64 let n = self.reader.lock().unwrap().read(&mut temp)?;
65 if n == 0 {
66 self.eof = true;
67 } else {
68 self.buffer.extend_from_slice(&temp[..n]);
69 }
70 }
71 self.position = offset.min(self.buffer.len());
72 Ok(self.position as u64)
73 }
74 SeekFrom::End(_) => Err(std::io::Error::new(
75 std::io::ErrorKind::Unsupported,
76 "cannot seek from end on a stream",
77 )),
78 SeekFrom::Current(offset) => {
79 let new_pos = if offset >= 0 {
80 self.position.saturating_add(offset as usize)
81 } else {
82 self.position.saturating_sub((-offset) as usize)
83 };
84 while new_pos > self.buffer.len() && !self.eof {
85 let mut temp = [0u8; 8192];
86 let n = self.reader.lock().unwrap().read(&mut temp)?;
87 if n == 0 {
88 self.eof = true;
89 } else {
90 self.buffer.extend_from_slice(&temp[..n]);
91 }
92 }
93 self.position = new_pos.min(self.buffer.len());
94 Ok(self.position as u64)
95 }
96 }
97 }
98}
99
100impl symphonia::core::io::MediaSource for HttpMediaSource {
101 fn is_seekable(&self) -> bool {
102 true
103 }
104
105 fn byte_len(&self) -> Option<u64> {
106 None
107 }
108}
109
15#[allow(dead_code)] 110#[allow(dead_code)]
16pub enum AudioCommand { 111pub enum AudioCommand {
17 Play(PathBuf), 112 Play(PathBuf),
@@ -426,10 +521,38 @@ fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Erro
426 use symphonia::core::formats::probe::Hint; 521 use symphonia::core::formats::probe::Hint;
427 use symphonia::core::io::MediaSourceStream; 522 use symphonia::core::io::MediaSourceStream;
428 523
429 let file = std::fs::File::open(path)?; 524 let path_str = path.to_string_lossy();
430 let mss = MediaSourceStream::new(Box::new(file), Default::default()); 525 let is_url = path_str.starts_with("http://") || path_str.starts_with("https://");
526
527 let mss: MediaSourceStream = if is_url {
528 let client = reqwest::blocking::Client::builder()
529 .user_agent("RSPlayer/0.8.2")
530 .connect_timeout(std::time::Duration::from_secs(10))
531 .build()
532 .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
533 let response = client
534 .get(path_str.as_ref())
535 .send()
536 .map_err(|e| format!("HTTP request failed: {}", e))?;
537 let status = response.status();
538 if !status.is_success() {
539 return Err(format!("HTTP error {} for URL", status).into());
540 }
541 let reader = HttpMediaSource::new(Box::new(response));
542 MediaSourceStream::new(Box::new(reader), Default::default())
543 } else {
544 let file = std::fs::File::open(path)?;
545 MediaSourceStream::new(Box::new(file), Default::default())
546 };
431 547
432 let hint = Hint::new(); 548 let mut hint = Hint::new();
549 if is_url
550 && let Some(ext) = Path::new(path_str.as_ref())
551 .extension()
552 .and_then(|e| e.to_str())
553 {
554 hint.with_extension(ext);
555 }
433 let format = symphonia::default::get_probe().probe( 556 let format = symphonia::default::get_probe().probe(
434 &hint, 557 &hint,
435 mss, 558 mss,
diff --git a/src/daemon.rs b/src/daemon.rs
index 20f4e7e..c794495 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -34,6 +34,14 @@ impl ScrobbleState {
34 } 34 }
35 35
36 fn start(&mut self, track: Track) { 36 fn start(&mut self, track: Track) {
37 if track.is_stream {
38 crate::debug_eprintln(format_args!(
39 "scrobble: not tracking stream '{}'",
40 track.path.display()
41 ));
42 self.clear();
43 return;
44 }
37 let ts = SystemTime::now() 45 let ts = SystemTime::now()
38 .duration_since(SystemTime::UNIX_EPOCH) 46 .duration_since(SystemTime::UNIX_EPOCH)
39 .unwrap_or_default() 47 .unwrap_or_default()
@@ -109,6 +117,7 @@ mod tests {
109 artist: Some("Artist".into()), 117 artist: Some("Artist".into()),
110 album: None, 118 album: None,
111 duration_secs: Some(200.0), 119 duration_secs: Some(200.0),
120 is_stream: false,
112 }; 121 };
113 s.start(t); 122 s.start(t);
114 assert!(s.track.is_some()); 123 assert!(s.track.is_some());
@@ -161,6 +170,13 @@ mod tests {
161} 170}
162 171
163fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { 172fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) {
173 if let Some(ref track) = state.track
174 && track.is_stream
175 {
176 crate::debug_eprintln(format_args!("scrobble: skipping scrobble for stream track"));
177 state.clear();
178 return;
179 }
164 if state.should_scrobble() 180 if state.should_scrobble()
165 && let (Some(track), Some(ts)) = (&state.track, state.timestamp) 181 && let (Some(track), Some(ts)) = (&state.track, state.timestamp)
166 { 182 {
@@ -189,6 +205,12 @@ fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState
189} 205}
190 206
191fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) { 207fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) {
208 if track.is_stream {
209 crate::debug_eprintln(format_args!(
210 "scrobble: skipping now_playing for stream track"
211 ));
212 return;
213 }
192 let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); 214 let artist = track.artist.as_deref().unwrap_or("(unknown artist)");
193 let title = track.title.as_deref().unwrap_or("(unknown title)"); 215 let title = track.title.as_deref().unwrap_or("(unknown title)");
194 let album = track.album.as_deref(); 216 let album = track.album.as_deref();
@@ -845,16 +867,18 @@ fn process_command(
845 return "Usage: play [now|next|last] <path>\n".to_string(); 867 return "Usage: play [now|next|last] <path>\n".to_string();
846 } 868 }
847 869
848 let path = std::path::PathBuf::from(path_str.clone()); 870 let is_url = path_str.starts_with("http://") || path_str.starts_with("https://");
849 if path.is_file() { 871
872 if is_url {
850 if let Ok(mut q) = queue.lock() { 873 if let Ok(mut q) = queue.lock() {
851 let was_empty = q.is_empty(); 874 let was_empty = q.is_empty();
875 let track = Track::from_url(&path_str);
852 match position { 876 match position {
853 "now" => { 877 "now" => {
854 if let Ok(mut s) = scrobble_state.lock() { 878 if let Ok(mut s) = scrobble_state.lock() {
855 try_scrobble_current(scrobbler, &mut s); 879 try_scrobble_current(scrobbler, &mut s);
856 } 880 }
857 q.insert_after_current(path.clone()); 881 q.push_track(track.clone());
858 if !was_empty { 882 if !was_empty {
859 let new_pos = q.position() + 1; 883 let new_pos = q.position() + 1;
860 q.set_position(new_pos); 884 q.set_position(new_pos);
@@ -872,84 +896,103 @@ fn process_command(
872 format!("Playing now: {}\n", path_str) 896 format!("Playing now: {}\n", path_str)
873 } 897 }
874 "next" => { 898 "next" => {
875 q.insert_after_current(path.clone()); 899 q.push_track(track);
876 format!("Queued next: {}\n", path_str) 900 format!("Queued next: {}\n", path_str)
877 } 901 }
878 _ => { 902 _ => {
879 let was_empty = q.is_empty();
880 let was_playing = audio.is_playing(); 903 let was_playing = audio.is_playing();
881 q.add_track(path.clone()); 904 q.push_track(track.clone());
882 if was_empty && !was_playing { 905 if was_empty
883 let track = Track::from_path(path.clone()); 906 && !was_playing
884 audio.play(path); 907 && let Some(track) = q.current()
885 send_now_playing(scrobbler, &track); 908 {
909 audio.play(track.path.clone());
910 send_now_playing(scrobbler, track);
886 if let Ok(mut s) = scrobble_state.lock() { 911 if let Ok(mut s) = scrobble_state.lock() {
887 s.start(track.clone()); 912 s.start(track.clone());
888 } 913 }
889 let fmt = 914 let fmt =
890 Config::load().ok().and_then(|c| c.display_format.clone()); 915 Config::load().ok().and_then(|c| c.display_format.clone());
891 notification::notify_track(&track, fmt.as_deref()); 916 notification::notify_track(track, fmt.as_deref());
892 format!("Playing: {}\n", path_str)
893 } else {
894 format!("Queued: {}\n", path_str)
895 } 917 }
918 format!("Queued: {}\n", path_str)
896 } 919 }
897 } 920 }
898 } else { 921 } else {
899 "Error accessing queue\n".to_string() 922 "Error accessing queue\n".to_string()
900 } 923 }
901 } else if path.is_dir() { 924 } else {
902 match find_audio_in_dir(&path) { 925 let path = std::path::PathBuf::from(path_str.clone());
903 Some(files) if !files.is_empty() => { 926 if path.is_file() {
904 if let Ok(mut q) = queue.lock() { 927 if let Ok(mut q) = queue.lock() {
905 let was_empty = q.is_empty(); 928 let was_empty = q.is_empty();
906 match position { 929 match position {
907 "now" => { 930 "now" => {
931 if let Ok(mut s) = scrobble_state.lock() {
932 try_scrobble_current(scrobbler, &mut s);
933 }
934 q.insert_after_current(path.clone());
935 if !was_empty {
936 let new_pos = q.position() + 1;
937 q.set_position(new_pos);
938 }
939 if let Some(track) = q.track_at(q.position()) {
940 audio.play(track.path.clone());
941 send_now_playing(scrobbler, track);
908 if let Ok(mut s) = scrobble_state.lock() { 942 if let Ok(mut s) = scrobble_state.lock() {
909 try_scrobble_current(scrobbler, &mut s); 943 s.start(track.clone());
910 }
911 for file in files.iter().rev() {
912 q.insert_after_current(file.clone());
913 }
914 if !was_empty {
915 let new_pos = q.position() + 1;
916 q.set_position(new_pos);
917 }
918 if let Some(track) = q.track_at(q.position()) {
919 audio.play(track.path.clone());
920 send_now_playing(scrobbler, track);
921 if let Ok(mut s) = scrobble_state.lock() {
922 s.start(track.clone());
923 }
924 let fmt = Config::load()
925 .ok()
926 .and_then(|c| c.display_format.clone());
927 notification::notify_track(track, fmt.as_deref());
928 } 944 }
929 format!( 945 let fmt =
930 "Playing now: {} tracks from {}\n", 946 Config::load().ok().and_then(|c| c.display_format.clone());
931 files.len(), 947 notification::notify_track(track, fmt.as_deref());
932 path_str
933 )
934 } 948 }
935 "next" => { 949 format!("Playing now: {}\n", path_str)
936 for file in files.iter().rev() { 950 }
937 q.insert_after_current(file.clone()); 951 "next" => {
952 q.insert_after_current(path.clone());
953 format!("Queued next: {}\n", path_str)
954 }
955 _ => {
956 let was_empty = q.is_empty();
957 let was_playing = audio.is_playing();
958 q.add_track(path.clone());
959 if was_empty && !was_playing {
960 let track = Track::from_path(path.clone());
961 audio.play(path);
962 send_now_playing(scrobbler, &track);
963 if let Ok(mut s) = scrobble_state.lock() {
964 s.start(track.clone());
938 } 965 }
939 format!( 966 let fmt =
940 "Queued next: {} tracks from {}\n", 967 Config::load().ok().and_then(|c| c.display_format.clone());
941 files.len(), 968 notification::notify_track(&track, fmt.as_deref());
942 path_str 969 format!("Playing: {}\n", path_str)
943 ) 970 } else {
971 format!("Queued: {}\n", path_str)
944 } 972 }
945 _ => { 973 }
946 let was_playing = audio.is_playing(); 974 }
947 for file in &files { 975 } else {
948 q.add_track(file.clone()); 976 "Error accessing queue\n".to_string()
949 } 977 }
950 if was_empty && !was_playing { 978 } else if path.is_dir() {
951 q.shuffle_initial(); 979 match find_audio_in_dir(&path) {
952 if let Some(track) = q.current() { 980 Some(files) if !files.is_empty() => {
981 if let Ok(mut q) = queue.lock() {
982 let was_empty = q.is_empty();
983 match position {
984 "now" => {
985 if let Ok(mut s) = scrobble_state.lock() {
986 try_scrobble_current(scrobbler, &mut s);
987 }
988 for file in files.iter().rev() {
989 q.insert_after_current(file.clone());
990 }
991 if !was_empty {
992 let new_pos = q.position() + 1;
993 q.set_position(new_pos);
994 }
995 if let Some(track) = q.track_at(q.position()) {
953 audio.play(track.path.clone()); 996 audio.play(track.path.clone());
954 send_now_playing(scrobbler, track); 997 send_now_playing(scrobbler, track);
955 if let Ok(mut s) = scrobble_state.lock() { 998 if let Ok(mut s) = scrobble_state.lock() {
@@ -960,18 +1003,57 @@ fn process_command(
960 .and_then(|c| c.display_format.clone()); 1003 .and_then(|c| c.display_format.clone());
961 notification::notify_track(track, fmt.as_deref()); 1004 notification::notify_track(track, fmt.as_deref());
962 } 1005 }
1006 format!(
1007 "Playing now: {} tracks from {}\n",
1008 files.len(),
1009 path_str
1010 )
1011 }
1012 "next" => {
1013 for file in files.iter().rev() {
1014 q.insert_after_current(file.clone());
1015 }
1016 format!(
1017 "Queued next: {} tracks from {}\n",
1018 files.len(),
1019 path_str
1020 )
1021 }
1022 _ => {
1023 let was_playing = audio.is_playing();
1024 for file in &files {
1025 q.add_track(file.clone());
1026 }
1027 if was_empty && !was_playing {
1028 q.shuffle_initial();
1029 if let Some(track) = q.current() {
1030 audio.play(track.path.clone());
1031 send_now_playing(scrobbler, track);
1032 if let Ok(mut s) = scrobble_state.lock() {
1033 s.start(track.clone());
1034 }
1035 let fmt = Config::load()
1036 .ok()
1037 .and_then(|c| c.display_format.clone());
1038 notification::notify_track(track, fmt.as_deref());
1039 }
1040 }
1041 format!(
1042 "Queued {} tracks from: {}\n",
1043 files.len(),
1044 path_str
1045 )
963 } 1046 }
964 format!("Queued {} tracks from: {}\n", files.len(), path_str)
965 } 1047 }
1048 } else {
1049 "Error accessing queue\n".to_string()
966 } 1050 }
967 } else {
968 "Error accessing queue\n".to_string()
969 } 1051 }
1052 _ => format!("Error: No audio files found in '{}'\n", path_str),
970 } 1053 }
971 _ => format!("Error: No audio files found in '{}'\n", path_str), 1054 } else {
1055 format!("File not found: {}\n", path_str)
972 } 1056 }
973 } else {
974 format!("File not found: {}\n", path_str)
975 } 1057 }
976 } 1058 }
977 _ if cmd == "volume" => { 1059 _ if cmd == "volume" => {
diff --git a/src/queue.rs b/src/queue.rs
index fc30c86..1742a00 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -87,10 +87,15 @@ pub struct Track {
87 pub artist: Option<String>, 87 pub artist: Option<String>,
88 pub album: Option<String>, 88 pub album: Option<String>,
89 pub duration_secs: Option<f64>, 89 pub duration_secs: Option<f64>,
90 pub is_stream: bool,
90} 91}
91 92
92impl Track { 93impl Track {
93 pub fn from_path(path: PathBuf) -> Self { 94 pub fn from_path(path: PathBuf) -> Self {
95 let path_str = path.to_string_lossy();
96 if path_str.starts_with("http://") || path_str.starts_with("https://") {
97 return Track::from_url(&path_str);
98 }
94 let metadata = extract_metadata(&path); 99 let metadata = extract_metadata(&path);
95 let metadata = metadata.as_ref(); 100 let metadata = metadata.as_ref();
96 Track { 101 Track {
@@ -99,6 +104,31 @@ impl Track {
99 artist: metadata.and_then(|m| m.artist.clone()), 104 artist: metadata.and_then(|m| m.artist.clone()),
100 album: metadata.and_then(|m| m.album.clone()), 105 album: metadata.and_then(|m| m.album.clone()),
101 duration_secs: metadata.and_then(|m| m.duration_secs), 106 duration_secs: metadata.and_then(|m| m.duration_secs),
107 is_stream: false,
108 }
109 }
110
111 pub fn from_url(url: &str) -> Self {
112 let clean_url = url.split('?').next().unwrap_or(url);
113 let url_path = Path::new(clean_url);
114 let title = url_path
115 .file_stem()
116 .and_then(|s| s.to_str())
117 .map(|s| s.to_string())
118 .or_else(|| {
119 url_path
120 .file_name()
121 .and_then(|s| s.to_str())
122 .map(|s| s.to_string())
123 })
124 .unwrap_or_else(|| url.to_string());
125 Track {
126 path: PathBuf::from(url),
127 title: Some(title),
128 artist: None,
129 album: None,
130 duration_secs: None,
131 is_stream: true,
102 } 132 }
103 } 133 }
104 134
diff --git a/tests/queue.rs b/tests/queue.rs
index 6166c74..5f4d853 100644
--- a/tests/queue.rs
+++ b/tests/queue.rs
@@ -15,6 +15,7 @@ fn track(path: &str, title: Option<&str>, artist: Option<&str>, album: Option<&s
15 artist: artist.map(|s| s.to_string()), 15 artist: artist.map(|s| s.to_string()),
16 album: album.map(|s| s.to_string()), 16 album: album.map(|s| s.to_string()),
17 duration_secs: None, 17 duration_secs: None,
18 is_stream: false,
18 } 19 }
19} 20}
20 21
@@ -25,6 +26,7 @@ fn track_no_meta(path: &str) -> Track {
25 artist: None, 26 artist: None,
26 album: None, 27 album: None,
27 duration_secs: None, 28 duration_secs: None,
29 is_stream: false,
28 } 30 }
29} 31}
30 32