diff options
| author | salmonik <raisersoft@proton.me> | 2026-05-27 20:46:33 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-05-27 20:46:33 +0200 |
| commit | 0ae9d89f18bf36407229798a3b4943604fb708e4 (patch) | |
| tree | 47fbc6ad1d22ddd575f9773d5e1d36dae7bd0815 | |
| parent | play [track] [-a/--album] option (diff) | |
| download | rsplayer-0ae9d89f18bf36407229798a3b4943604fb708e4.tar.gz rsplayer-0ae9d89f18bf36407229798a3b4943604fb708e4.zip | |
Add internet streams support
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/audio.rs | 131 | ||||
| -rw-r--r-- | src/daemon.rs | 216 | ||||
| -rw-r--r-- | src/queue.rs | 30 | ||||
| -rw-r--r-- | tests/queue.rs | 2 |
6 files changed, 310 insertions, 73 deletions
| @@ -1948,7 +1948,7 @@ dependencies = [ | |||
| 1948 | 1948 | ||
| 1949 | [[package]] | 1949 | [[package]] |
| 1950 | name = "rsp" | 1950 | name = "rsp" |
| 1951 | version = "0.8.2" | 1951 | version = "0.8.3" |
| 1952 | dependencies = [ | 1952 | dependencies = [ |
| 1953 | "clap", | 1953 | "clap", |
| 1954 | "clap_complete", | 1954 | "clap_complete", |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "rsp" | 2 | name = "rsp" |
| 3 | version = "0.8.2" | 3 | version = "0.8.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" |
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 | ||
| 8 | use crate::notification; | 8 | use crate::notification; |
| 9 | use std::path::PathBuf; | 9 | use std::io::{Read, Seek, SeekFrom}; |
| 10 | use std::path::{Path, PathBuf}; | ||
| 10 | use std::sync::atomic::{AtomicBool, Ordering}; | 11 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 11 | use std::sync::mpsc::{self, Receiver, Sender}; | 12 | use std::sync::mpsc::{self, Receiver, Sender}; |
| 12 | use std::sync::{Arc, Mutex}; | 13 | use std::sync::{Arc, Mutex}; |
| 13 | use std::thread; | 14 | use std::thread; |
| 14 | 15 | ||
| 16 | struct HttpMediaSource { | ||
| 17 | reader: Mutex<Box<dyn Read + Send>>, | ||
| 18 | buffer: Vec<u8>, | ||
| 19 | position: usize, | ||
| 20 | eof: bool, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl 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 | |||
| 34 | impl 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 | |||
| 57 | impl 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 | |||
| 100 | impl 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)] |
| 16 | pub enum AudioCommand { | 111 | pub 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 | ||
| 163 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | 172 | fn 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 | ||
| 191 | fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) { | 207 | fn 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 | ||
| 92 | impl Track { | 93 | impl 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 | ||
