From 0ae9d89f18bf36407229798a3b4943604fb708e4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 27 May 2026 20:46:33 +0200 Subject: Add internet streams support --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/audio.rs | 131 ++++++++++++++++++++++++++++++++-- src/daemon.rs | 216 +++++++++++++++++++++++++++++++++++++++------------------ src/queue.rs | 30 ++++++++ tests/queue.rs | 2 + 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 = [ [[package]] name = "rsp" -version = "0.8.2" +version = "0.8.3" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 49929c0..ef61127 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsp" -version = "0.8.2" +version = "0.8.3" edition = "2024" description = "Rust music player daemon with Unix socket IPC" license = "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 @@ */ use crate::notification; -use std::path::PathBuf; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread; +struct HttpMediaSource { + reader: Mutex>, + buffer: Vec, + position: usize, + eof: bool, +} + +impl HttpMediaSource { + fn new(reader: Box) -> Self { + HttpMediaSource { + reader: Mutex::new(reader), + buffer: Vec::new(), + position: 0, + eof: false, + } + } +} + +impl Read for HttpMediaSource { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.position < self.buffer.len() { + let available = self.buffer.len() - self.position; + let to_copy = buf.len().min(available); + buf[..to_copy].copy_from_slice(&self.buffer[self.position..self.position + to_copy]); + self.position += to_copy; + return Ok(to_copy); + } + if self.eof { + return Ok(0); + } + let n = self.reader.lock().unwrap().read(buf)?; + if n > 0 { + self.buffer.extend_from_slice(&buf[..n]); + self.position += n; + } else { + self.eof = true; + } + Ok(n) + } +} + +impl Seek for HttpMediaSource { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + match pos { + SeekFrom::Start(offset) => { + let offset = offset as usize; + while offset > self.buffer.len() && !self.eof { + let mut temp = [0u8; 8192]; + let n = self.reader.lock().unwrap().read(&mut temp)?; + if n == 0 { + self.eof = true; + } else { + self.buffer.extend_from_slice(&temp[..n]); + } + } + self.position = offset.min(self.buffer.len()); + Ok(self.position as u64) + } + SeekFrom::End(_) => Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "cannot seek from end on a stream", + )), + SeekFrom::Current(offset) => { + let new_pos = if offset >= 0 { + self.position.saturating_add(offset as usize) + } else { + self.position.saturating_sub((-offset) as usize) + }; + while new_pos > self.buffer.len() && !self.eof { + let mut temp = [0u8; 8192]; + let n = self.reader.lock().unwrap().read(&mut temp)?; + if n == 0 { + self.eof = true; + } else { + self.buffer.extend_from_slice(&temp[..n]); + } + } + self.position = new_pos.min(self.buffer.len()); + Ok(self.position as u64) + } + } + } +} + +impl symphonia::core::io::MediaSource for HttpMediaSource { + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + None + } +} + #[allow(dead_code)] pub enum AudioCommand { Play(PathBuf), @@ -426,10 +521,38 @@ fn open_audio_file(path: &PathBuf) -> Result, state: &mut ScrobbleState) { + if let Some(ref track) = state.track + && track.is_stream + { + crate::debug_eprintln(format_args!("scrobble: skipping scrobble for stream track")); + state.clear(); + return; + } if state.should_scrobble() && let (Some(track), Some(ts)) = (&state.track, state.timestamp) { @@ -189,6 +205,12 @@ fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState } fn send_now_playing(scrobbler: &RwLock, track: &Track) { + if track.is_stream { + crate::debug_eprintln(format_args!( + "scrobble: skipping now_playing for stream track" + )); + return; + } let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); let title = track.title.as_deref().unwrap_or("(unknown title)"); let album = track.album.as_deref(); @@ -845,16 +867,18 @@ fn process_command( return "Usage: play [now|next|last] \n".to_string(); } - let path = std::path::PathBuf::from(path_str.clone()); - if path.is_file() { + let is_url = path_str.starts_with("http://") || path_str.starts_with("https://"); + + if is_url { if let Ok(mut q) = queue.lock() { let was_empty = q.is_empty(); + let track = Track::from_url(&path_str); match position { "now" => { if let Ok(mut s) = scrobble_state.lock() { try_scrobble_current(scrobbler, &mut s); } - q.insert_after_current(path.clone()); + q.push_track(track.clone()); if !was_empty { let new_pos = q.position() + 1; q.set_position(new_pos); @@ -872,84 +896,103 @@ fn process_command( format!("Playing now: {}\n", path_str) } "next" => { - q.insert_after_current(path.clone()); + q.push_track(track); format!("Queued next: {}\n", path_str) } _ => { - let was_empty = q.is_empty(); let was_playing = audio.is_playing(); - q.add_track(path.clone()); - if was_empty && !was_playing { - let track = Track::from_path(path.clone()); - audio.play(path); - send_now_playing(scrobbler, &track); + q.push_track(track.clone()); + if was_empty + && !was_playing + && let Some(track) = q.current() + { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); - notification::notify_track(&track, fmt.as_deref()); - format!("Playing: {}\n", path_str) - } else { - format!("Queued: {}\n", path_str) + notification::notify_track(track, fmt.as_deref()); } + format!("Queued: {}\n", path_str) } } } else { "Error accessing queue\n".to_string() } - } else if path.is_dir() { - match find_audio_in_dir(&path) { - Some(files) if !files.is_empty() => { - if let Ok(mut q) = queue.lock() { - let was_empty = q.is_empty(); - match position { - "now" => { + } else { + let path = std::path::PathBuf::from(path_str.clone()); + if path.is_file() { + if let Ok(mut q) = queue.lock() { + let was_empty = q.is_empty(); + match position { + "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } + q.insert_after_current(path.clone()); + if !was_empty { + let new_pos = q.position() + 1; + q.set_position(new_pos); + } + if let Some(track) = q.track_at(q.position()) { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { - try_scrobble_current(scrobbler, &mut s); - } - for file in files.iter().rev() { - q.insert_after_current(file.clone()); - } - if !was_empty { - let new_pos = q.position() + 1; - q.set_position(new_pos); - } - if let Some(track) = q.track_at(q.position()) { - audio.play(track.path.clone()); - send_now_playing(scrobbler, track); - if let Ok(mut s) = scrobble_state.lock() { - s.start(track.clone()); - } - let fmt = Config::load() - .ok() - .and_then(|c| c.display_format.clone()); - notification::notify_track(track, fmt.as_deref()); + s.start(track.clone()); } - format!( - "Playing now: {} tracks from {}\n", - files.len(), - path_str - ) + let fmt = + Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); } - "next" => { - for file in files.iter().rev() { - q.insert_after_current(file.clone()); + format!("Playing now: {}\n", path_str) + } + "next" => { + q.insert_after_current(path.clone()); + format!("Queued next: {}\n", path_str) + } + _ => { + let was_empty = q.is_empty(); + let was_playing = audio.is_playing(); + q.add_track(path.clone()); + if was_empty && !was_playing { + let track = Track::from_path(path.clone()); + audio.play(path); + send_now_playing(scrobbler, &track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); } - format!( - "Queued next: {} tracks from {}\n", - files.len(), - path_str - ) + let fmt = + Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(&track, fmt.as_deref()); + format!("Playing: {}\n", path_str) + } else { + format!("Queued: {}\n", path_str) } - _ => { - let was_playing = audio.is_playing(); - for file in &files { - q.add_track(file.clone()); - } - if was_empty && !was_playing { - q.shuffle_initial(); - if let Some(track) = q.current() { + } + } + } else { + "Error accessing queue\n".to_string() + } + } else if path.is_dir() { + match find_audio_in_dir(&path) { + Some(files) if !files.is_empty() => { + if let Ok(mut q) = queue.lock() { + let was_empty = q.is_empty(); + match position { + "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } + for file in files.iter().rev() { + q.insert_after_current(file.clone()); + } + if !was_empty { + let new_pos = q.position() + 1; + q.set_position(new_pos); + } + if let Some(track) = q.track_at(q.position()) { audio.play(track.path.clone()); send_now_playing(scrobbler, track); if let Ok(mut s) = scrobble_state.lock() { @@ -960,18 +1003,57 @@ fn process_command( .and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); } + format!( + "Playing now: {} tracks from {}\n", + files.len(), + path_str + ) + } + "next" => { + for file in files.iter().rev() { + q.insert_after_current(file.clone()); + } + format!( + "Queued next: {} tracks from {}\n", + files.len(), + path_str + ) + } + _ => { + let was_playing = audio.is_playing(); + for file in &files { + q.add_track(file.clone()); + } + if was_empty && !was_playing { + q.shuffle_initial(); + if let Some(track) = q.current() { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } + let fmt = Config::load() + .ok() + .and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); + } + } + format!( + "Queued {} tracks from: {}\n", + files.len(), + path_str + ) } - format!("Queued {} tracks from: {}\n", files.len(), path_str) } + } else { + "Error accessing queue\n".to_string() } - } else { - "Error accessing queue\n".to_string() } + _ => format!("Error: No audio files found in '{}'\n", path_str), } - _ => format!("Error: No audio files found in '{}'\n", path_str), + } else { + format!("File not found: {}\n", path_str) } - } else { - format!("File not found: {}\n", path_str) } } _ 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 { pub artist: Option, pub album: Option, pub duration_secs: Option, + pub is_stream: bool, } impl Track { pub fn from_path(path: PathBuf) -> Self { + let path_str = path.to_string_lossy(); + if path_str.starts_with("http://") || path_str.starts_with("https://") { + return Track::from_url(&path_str); + } let metadata = extract_metadata(&path); let metadata = metadata.as_ref(); Track { @@ -99,6 +104,31 @@ impl Track { artist: metadata.and_then(|m| m.artist.clone()), album: metadata.and_then(|m| m.album.clone()), duration_secs: metadata.and_then(|m| m.duration_secs), + is_stream: false, + } + } + + pub fn from_url(url: &str) -> Self { + let clean_url = url.split('?').next().unwrap_or(url); + let url_path = Path::new(clean_url); + let title = url_path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .or_else(|| { + url_path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| url.to_string()); + Track { + path: PathBuf::from(url), + title: Some(title), + artist: None, + album: None, + duration_secs: None, + is_stream: true, } } 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 artist: artist.map(|s| s.to_string()), album: album.map(|s| s.to_string()), duration_secs: None, + is_stream: false, } } @@ -25,6 +26,7 @@ fn track_no_meta(path: &str) -> Track { artist: None, album: None, duration_secs: None, + is_stream: false, } } -- cgit v1.2.3