diff options
Diffstat (limited to '')
| -rw-r--r-- | src/audio.rs | 418 | ||||
| -rw-r--r-- | src/cli.rs | 186 | ||||
| -rw-r--r-- | src/config.rs | 180 | ||||
| -rw-r--r-- | src/daemon.rs | 574 | ||||
| -rw-r--r-- | src/database.rs | 259 | ||||
| -rw-r--r-- | src/main.rs | 18 | ||||
| -rw-r--r-- | src/playback.rs | 131 | ||||
| -rw-r--r-- | src/scrobbler.rs | 75 |
8 files changed, 1841 insertions, 0 deletions
diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..86c88f7 --- /dev/null +++ b/src/audio.rs | |||
| @@ -0,0 +1,418 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | use std::sync::atomic::{AtomicBool, Ordering}; | ||
| 3 | use std::sync::mpsc::{self, Receiver, Sender}; | ||
| 4 | use std::sync::{Arc, Mutex}; | ||
| 5 | use std::thread; | ||
| 6 | |||
| 7 | #[allow(dead_code)] | ||
| 8 | pub enum AudioCommand { | ||
| 9 | Play(PathBuf), | ||
| 10 | Pause, | ||
| 11 | Resume, | ||
| 12 | Stop, | ||
| 13 | SetVolume(f32), | ||
| 14 | } | ||
| 15 | |||
| 16 | pub struct AudioEngine { | ||
| 17 | command_tx: Sender<AudioCommand>, | ||
| 18 | is_playing: Arc<AtomicBool>, | ||
| 19 | current_track: Arc<Mutex<Option<std::path::PathBuf>>>, | ||
| 20 | track_ended: Arc<AtomicBool>, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl AudioEngine { | ||
| 24 | pub fn new() -> Self { | ||
| 25 | let (tx, rx) = mpsc::channel(); | ||
| 26 | let is_playing = Arc::new(AtomicBool::new(false)); | ||
| 27 | let is_playing_clone = Arc::clone(&is_playing); | ||
| 28 | let current_track = Arc::new(Mutex::new(None)); | ||
| 29 | let current_track_clone = Arc::clone(¤t_track); | ||
| 30 | let track_ended = Arc::new(AtomicBool::new(false)); | ||
| 31 | let track_ended_clone = Arc::clone(&track_ended); | ||
| 32 | |||
| 33 | let volume = Arc::new(Mutex::new(1.0f32)); | ||
| 34 | let volume_clone = Arc::clone(&volume); | ||
| 35 | |||
| 36 | thread::spawn(move || { | ||
| 37 | audio_thread( | ||
| 38 | rx, | ||
| 39 | is_playing_clone, | ||
| 40 | current_track_clone, | ||
| 41 | volume_clone, | ||
| 42 | track_ended_clone, | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | AudioEngine { | ||
| 47 | command_tx: tx, | ||
| 48 | is_playing, | ||
| 49 | current_track, | ||
| 50 | track_ended, | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | pub fn play(&self, path: PathBuf) { | ||
| 55 | self.track_ended.store(false, Ordering::SeqCst); | ||
| 56 | if let Ok(mut track) = self.current_track.lock() { | ||
| 57 | *track = Some(path.clone()); | ||
| 58 | } | ||
| 59 | let _ = self.command_tx.send(AudioCommand::Play(path)); | ||
| 60 | } | ||
| 61 | |||
| 62 | pub fn pause(&self) { | ||
| 63 | let _ = self.command_tx.send(AudioCommand::Pause); | ||
| 64 | } | ||
| 65 | |||
| 66 | pub fn resume(&self) { | ||
| 67 | let _ = self.command_tx.send(AudioCommand::Resume); | ||
| 68 | } | ||
| 69 | |||
| 70 | pub fn stop(&self) { | ||
| 71 | let _ = self.command_tx.send(AudioCommand::Stop); | ||
| 72 | } | ||
| 73 | |||
| 74 | pub fn set_volume(&self, volume: f32) { | ||
| 75 | let _ = self.command_tx.send(AudioCommand::SetVolume(volume)); | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn is_playing(&self) -> bool { | ||
| 79 | self.is_playing.load(Ordering::SeqCst) | ||
| 80 | } | ||
| 81 | |||
| 82 | pub fn current_track(&self) -> Option<std::path::PathBuf> { | ||
| 83 | self.current_track.lock().ok().and_then(|g| g.clone()) | ||
| 84 | } | ||
| 85 | |||
| 86 | pub fn poll_track_ended(&self) -> bool { | ||
| 87 | self.track_ended.swap(false, Ordering::SeqCst) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | struct AudioData { | ||
| 92 | format: Box<dyn symphonia::core::formats::FormatReader>, | ||
| 93 | decoder: Box<dyn symphonia::core::codecs::Decoder>, | ||
| 94 | sample_rate: u32, | ||
| 95 | channels: u16, | ||
| 96 | sample_buffer: Vec<f32>, | ||
| 97 | buffer_pos: usize, | ||
| 98 | resample_ratio: Option<f64>, | ||
| 99 | } | ||
| 100 | |||
| 101 | fn resample_linear(input: &[f32], ratio: f64, channels: usize) -> Vec<f32> { | ||
| 102 | if ratio == 1.0 { | ||
| 103 | return input.to_vec(); | ||
| 104 | } | ||
| 105 | let input_frames = input.len() / channels; | ||
| 106 | let output_frames = (input_frames as f64 * ratio).ceil() as usize; | ||
| 107 | let mut output = Vec::with_capacity(output_frames * channels); | ||
| 108 | for i in 0..output_frames { | ||
| 109 | let input_pos = i as f64 / ratio; | ||
| 110 | let idx = input_pos.floor() as usize; | ||
| 111 | let frac = input_pos.fract() as f32; | ||
| 112 | for ch in 0..channels { | ||
| 113 | let cur = if idx * channels + ch < input.len() { | ||
| 114 | input[idx * channels + ch] | ||
| 115 | } else { | ||
| 116 | 0.0 | ||
| 117 | }; | ||
| 118 | let next = if (idx + 1) * channels + ch < input.len() { | ||
| 119 | input[(idx + 1) * channels + ch] | ||
| 120 | } else { | ||
| 121 | cur | ||
| 122 | }; | ||
| 123 | output.push(cur + frac * (next - cur)); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | output | ||
| 127 | } | ||
| 128 | |||
| 129 | fn audio_thread( | ||
| 130 | rx: Receiver<AudioCommand>, | ||
| 131 | is_playing: Arc<AtomicBool>, | ||
| 132 | current_track: Arc<Mutex<Option<std::path::PathBuf>>>, | ||
| 133 | volume: Arc<Mutex<f32>>, | ||
| 134 | track_ended: Arc<AtomicBool>, | ||
| 135 | ) { | ||
| 136 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | ||
| 137 | |||
| 138 | let host = cpal::default_host(); | ||
| 139 | let device = match host.default_output_device() { | ||
| 140 | Some(d) => d, | ||
| 141 | None => { | ||
| 142 | eprintln!("No output device available"); | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | }; | ||
| 146 | |||
| 147 | let config = match device.default_output_config() { | ||
| 148 | Ok(c) => c, | ||
| 149 | Err(e) => { | ||
| 150 | eprintln!("Failed to get output config: {}", e); | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | }; | ||
| 154 | |||
| 155 | let device_sample_rate = config.sample_rate(); | ||
| 156 | let channels = config.channels(); | ||
| 157 | eprintln!( | ||
| 158 | "Default output: {} Hz, {} channels", | ||
| 159 | device_sample_rate, channels | ||
| 160 | ); | ||
| 161 | |||
| 162 | let mut current_stream: Option<cpal::Stream> = None; | ||
| 163 | let audio_data: Arc<Mutex<Option<AudioData>>> = Arc::new(Mutex::new(None)); | ||
| 164 | |||
| 165 | while let Ok(cmd) = rx.recv() { | ||
| 166 | match cmd { | ||
| 167 | AudioCommand::Play(path) => { | ||
| 168 | is_playing.store(true, Ordering::SeqCst); | ||
| 169 | if let Ok(mut track) = current_track.lock() { | ||
| 170 | *track = Some(path.clone()); | ||
| 171 | } | ||
| 172 | |||
| 173 | // Drop the current stream before creating a new one | ||
| 174 | current_stream = None; | ||
| 175 | |||
| 176 | match open_audio_file(&path) { | ||
| 177 | Ok(mut data) => { | ||
| 178 | eprintln!("File: {} Hz, {} channels", data.sample_rate, data.channels); | ||
| 179 | |||
| 180 | if data.sample_rate != device_sample_rate { | ||
| 181 | eprintln!( | ||
| 182 | "Resampling from {} Hz to {} Hz", | ||
| 183 | data.sample_rate, device_sample_rate | ||
| 184 | ); | ||
| 185 | data.resample_ratio = | ||
| 186 | Some(device_sample_rate as f64 / data.sample_rate as f64); | ||
| 187 | } | ||
| 188 | |||
| 189 | let stream_config: cpal::StreamConfig = | ||
| 190 | device.default_output_config().unwrap().into(); | ||
| 191 | |||
| 192 | let audio_data_clone = Arc::clone(&audio_data); | ||
| 193 | let vol = Arc::clone(&volume); | ||
| 194 | let ended = Arc::clone(&track_ended); | ||
| 195 | let is_playing_clone = Arc::clone(&is_playing); | ||
| 196 | |||
| 197 | if let Ok(mut d) = audio_data.lock() { | ||
| 198 | *d = Some(data); | ||
| 199 | } | ||
| 200 | |||
| 201 | match device.build_output_stream( | ||
| 202 | &stream_config, | ||
| 203 | move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { | ||
| 204 | let vol = vol.lock().map(|v| *v).unwrap_or(1.0); | ||
| 205 | |||
| 206 | if let Ok(mut audio_data) = audio_data_clone.lock() { | ||
| 207 | if let Some(ref mut state) = *audio_data { | ||
| 208 | let channels = state.channels as usize; | ||
| 209 | let mut offset = 0usize; | ||
| 210 | |||
| 211 | // Keep decoding until we fill the buffer or run out of data | ||
| 212 | while offset < data.len() { | ||
| 213 | // First, copy any buffered samples | ||
| 214 | if state.buffer_pos < state.sample_buffer.len() { | ||
| 215 | let remaining = | ||
| 216 | &state.sample_buffer[state.buffer_pos..]; | ||
| 217 | let to_copy = | ||
| 218 | remaining.len().min(data.len() - offset); | ||
| 219 | data[offset..offset + to_copy] | ||
| 220 | .copy_from_slice(&remaining[..to_copy]); | ||
| 221 | state.buffer_pos += to_copy; | ||
| 222 | offset += to_copy; | ||
| 223 | |||
| 224 | // If we've consumed all buffered samples, clear the buffer | ||
| 225 | if state.buffer_pos >= state.sample_buffer.len() { | ||
| 226 | state.sample_buffer.clear(); | ||
| 227 | state.buffer_pos = 0; | ||
| 228 | } | ||
| 229 | continue; // Try to fill more of the buffer | ||
| 230 | } | ||
| 231 | |||
| 232 | // Decode more data | ||
| 233 | match state.format.next_packet() { | ||
| 234 | Ok(packet) => { | ||
| 235 | match state.decoder.decode(&packet) { | ||
| 236 | Ok(decoded) => { | ||
| 237 | let spec = *decoded.spec(); | ||
| 238 | let mut sample_buf = | ||
| 239 | symphonia::core::audio::SampleBuffer::< | ||
| 240 | f32, | ||
| 241 | >::new( | ||
| 242 | decoded.capacity() as u64, spec | ||
| 243 | ); | ||
| 244 | sample_buf | ||
| 245 | .copy_interleaved_ref(decoded); | ||
| 246 | let samples = sample_buf.samples(); | ||
| 247 | |||
| 248 | // Resample if needed | ||
| 249 | let resampled = if let Some(ratio) = | ||
| 250 | state.resample_ratio | ||
| 251 | { | ||
| 252 | resample_linear( | ||
| 253 | samples, ratio, channels, | ||
| 254 | ) | ||
| 255 | } else { | ||
| 256 | samples.to_vec() | ||
| 257 | }; | ||
| 258 | |||
| 259 | // Copy what we can to output | ||
| 260 | let to_copy = resampled | ||
| 261 | .len() | ||
| 262 | .min(data.len() - offset); | ||
| 263 | data[offset..offset + to_copy] | ||
| 264 | .copy_from_slice( | ||
| 265 | &resampled[..to_copy], | ||
| 266 | ); | ||
| 267 | offset += to_copy; | ||
| 268 | |||
| 269 | // Buffer the rest | ||
| 270 | if to_copy < resampled.len() { | ||
| 271 | state.sample_buffer.clear(); | ||
| 272 | state | ||
| 273 | .sample_buffer | ||
| 274 | .extend_from_slice( | ||
| 275 | &resampled[to_copy..], | ||
| 276 | ); | ||
| 277 | state.buffer_pos = 0; | ||
| 278 | } | ||
| 279 | } | ||
| 280 | Err(_) => break, | ||
| 281 | } | ||
| 282 | } | ||
| 283 | Err(_) => break, | ||
| 284 | } | ||
| 285 | } | ||
| 286 | |||
| 287 | // Apply volume | ||
| 288 | for sample in data.iter_mut().take(offset) { | ||
| 289 | *sample *= vol; | ||
| 290 | } | ||
| 291 | |||
| 292 | // Fill remaining with silence | ||
| 293 | for sample in data.iter_mut().skip(offset) { | ||
| 294 | *sample = 0.0; | ||
| 295 | } | ||
| 296 | |||
| 297 | // If no more data and offset is 0, track ended | ||
| 298 | if offset == 0 && state.sample_buffer.is_empty() { | ||
| 299 | ended.store(true, Ordering::SeqCst); | ||
| 300 | is_playing_clone.store(false, Ordering::SeqCst); | ||
| 301 | // Clear audio data so we don't keep triggering ended | ||
| 302 | if let Ok(mut audio_data) = audio_data_clone.lock() { | ||
| 303 | *audio_data = None; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | } else { | ||
| 307 | for sample in data.iter_mut() { | ||
| 308 | *sample = 0.0; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | } | ||
| 312 | }, | ||
| 313 | |err| { | ||
| 314 | eprintln!("Stream error: {}", err); | ||
| 315 | }, | ||
| 316 | None, | ||
| 317 | ) { | ||
| 318 | Ok(stream) => { | ||
| 319 | if let Err(e) = stream.play() { | ||
| 320 | eprintln!("Failed to start stream: {}", e); | ||
| 321 | is_playing.store(false, Ordering::SeqCst); | ||
| 322 | track_ended.store(true, Ordering::SeqCst); | ||
| 323 | } else { | ||
| 324 | current_stream = Some(stream); | ||
| 325 | } | ||
| 326 | } | ||
| 327 | Err(e) => { | ||
| 328 | eprintln!("Failed to create stream: {}", e); | ||
| 329 | is_playing.store(false, Ordering::SeqCst); | ||
| 330 | track_ended.store(true, Ordering::SeqCst); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | } | ||
| 334 | Err(e) => { | ||
| 335 | eprintln!("Failed to open audio: {}", e); | ||
| 336 | is_playing.store(false, Ordering::SeqCst); | ||
| 337 | track_ended.store(true, Ordering::SeqCst); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | } | ||
| 341 | AudioCommand::Pause => { | ||
| 342 | if let Some(ref stream) = current_stream { | ||
| 343 | if let Err(e) = stream.pause() { | ||
| 344 | eprintln!("Failed to pause stream: {}", e); | ||
| 345 | } | ||
| 346 | is_playing.store(false, Ordering::SeqCst); | ||
| 347 | } | ||
| 348 | } | ||
| 349 | AudioCommand::Resume => { | ||
| 350 | if let Some(ref stream) = current_stream { | ||
| 351 | if let Err(e) = stream.play() { | ||
| 352 | eprintln!("Failed to resume stream: {}", e); | ||
| 353 | } else { | ||
| 354 | is_playing.store(true, Ordering::SeqCst); | ||
| 355 | } | ||
| 356 | } | ||
| 357 | } | ||
| 358 | AudioCommand::Stop => { | ||
| 359 | current_stream = None; | ||
| 360 | if let Ok(mut d) = audio_data.lock() { | ||
| 361 | *d = None; | ||
| 362 | } | ||
| 363 | is_playing.store(false, Ordering::SeqCst); | ||
| 364 | if let Ok(mut track) = current_track.lock() { | ||
| 365 | *track = None; | ||
| 366 | } | ||
| 367 | } | ||
| 368 | AudioCommand::SetVolume(vol) => { | ||
| 369 | if let Ok(mut volume_ref) = volume.lock() { | ||
| 370 | *volume_ref = vol; | ||
| 371 | } | ||
| 372 | } | ||
| 373 | } | ||
| 374 | } | ||
| 375 | } | ||
| 376 | |||
| 377 | fn open_audio_file(path: &PathBuf) -> Result<AudioData, Box<dyn std::error::Error>> { | ||
| 378 | use symphonia::core::audio::Channels; | ||
| 379 | use symphonia::core::io::MediaSourceStream; | ||
| 380 | |||
| 381 | let file = std::fs::File::open(path)?; | ||
| 382 | let mss = MediaSourceStream::new(Box::new(file), Default::default()); | ||
| 383 | |||
| 384 | let hint = symphonia::core::probe::Hint::new(); | ||
| 385 | let probed = symphonia::default::get_probe().format( | ||
| 386 | &hint, | ||
| 387 | mss, | ||
| 388 | &Default::default(), | ||
| 389 | &Default::default(), | ||
| 390 | )?; | ||
| 391 | |||
| 392 | let format = probed.format; | ||
| 393 | let track = format | ||
| 394 | .tracks() | ||
| 395 | .iter() | ||
| 396 | .find(|t| t.codec_params.sample_rate.is_some()) | ||
| 397 | .ok_or("No suitable track found")?; | ||
| 398 | |||
| 399 | let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); | ||
| 400 | let channels = track | ||
| 401 | .codec_params | ||
| 402 | .channels | ||
| 403 | .unwrap_or(Channels::FRONT_LEFT | Channels::FRONT_RIGHT) | ||
| 404 | .count() as u16; | ||
| 405 | |||
| 406 | let decoder = | ||
| 407 | symphonia::default::get_codecs().make(&track.codec_params, &Default::default())?; | ||
| 408 | |||
| 409 | Ok(AudioData { | ||
| 410 | format, | ||
| 411 | decoder, | ||
| 412 | sample_rate, | ||
| 413 | channels, | ||
| 414 | sample_buffer: Vec::new(), | ||
| 415 | buffer_pos: 0, | ||
| 416 | resample_ratio: None, | ||
| 417 | }) | ||
| 418 | } | ||
diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..13ef67c --- /dev/null +++ b/src/cli.rs | |||
| @@ -0,0 +1,186 @@ | |||
| 1 | use crate::config::Config; | ||
| 2 | use crate::database::Database; | ||
| 3 | use clap::{Parser, Subcommand}; | ||
| 4 | use std::path::PathBuf; | ||
| 5 | |||
| 6 | #[derive(Parser)] | ||
| 7 | #[command(name = "rsp")] | ||
| 8 | #[command(about = "Rust music player daemon", long_about = None)] | ||
| 9 | pub struct Cli { | ||
| 10 | #[command(subcommand)] | ||
| 11 | pub command: Commands, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Subcommand)] | ||
| 15 | pub enum Commands { | ||
| 16 | /// Start the daemon | ||
| 17 | Start, | ||
| 18 | /// Stop the daemon | ||
| 19 | Stop, | ||
| 20 | /// Restart the daemon (kill existing and start new) | ||
| 21 | Restart, | ||
| 22 | /// Play a track, album, or artist | ||
| 23 | Play { | ||
| 24 | #[arg(value_name = "QUERY")] | ||
| 25 | query: Option<String>, | ||
| 26 | }, | ||
| 27 | /// Pause playback | ||
| 28 | Pause, | ||
| 29 | /// Resume playback | ||
| 30 | Resume, | ||
| 31 | /// Stop playback | ||
| 32 | StopPlayback, | ||
| 33 | /// Skip to next track | ||
| 34 | Next, | ||
| 35 | /// Skip to previous track | ||
| 36 | Previous, | ||
| 37 | /// Set volume (0-100) | ||
| 38 | Volume { | ||
| 39 | #[arg(value_name = "LEVEL")] | ||
| 40 | level: Option<u8>, | ||
| 41 | }, | ||
| 42 | /// Shuffle mode | ||
| 43 | Shuffle { | ||
| 44 | #[arg(value_name = "MODE")] | ||
| 45 | mode: Option<String>, | ||
| 46 | }, | ||
| 47 | /// Queue management | ||
| 48 | Queue { | ||
| 49 | #[arg(value_name = "POSITION")] | ||
| 50 | position: Option<String>, | ||
| 51 | #[arg(value_name = "QUERY")] | ||
| 52 | query: Option<String>, | ||
| 53 | }, | ||
| 54 | /// Show player status | ||
| 55 | Status, | ||
| 56 | /// Scan music paths to populate database | ||
| 57 | Scan { | ||
| 58 | #[arg(long)] | ||
| 59 | force: bool, | ||
| 60 | #[arg(long, value_name = "PATHS")] | ||
| 61 | paths: Option<Vec<PathBuf>>, | ||
| 62 | }, | ||
| 63 | /// Settings management | ||
| 64 | Settings { | ||
| 65 | #[command(subcommand)] | ||
| 66 | command: SettingsCommands, | ||
| 67 | }, | ||
| 68 | } | ||
| 69 | |||
| 70 | #[derive(Subcommand)] | ||
| 71 | pub enum SettingsCommands { | ||
| 72 | /// Get a setting value | ||
| 73 | Get { | ||
| 74 | #[arg(value_name = "KEY")] | ||
| 75 | key: String, | ||
| 76 | }, | ||
| 77 | /// Set a setting value | ||
| 78 | Set { | ||
| 79 | #[arg(value_name = "KEY")] | ||
| 80 | key: String, | ||
| 81 | #[arg(value_name = "VALUE")] | ||
| 82 | value: String, | ||
| 83 | }, | ||
| 84 | /// List all settings | ||
| 85 | List, | ||
| 86 | /// Unset a setting | ||
| 87 | Unset { | ||
| 88 | #[arg(value_name = "KEY")] | ||
| 89 | key: String, | ||
| 90 | }, | ||
| 91 | } | ||
| 92 | |||
| 93 | impl Cli { | ||
| 94 | pub fn run(&self) -> Result<(), Box<dyn std::error::Error>> { | ||
| 95 | match &self.command { | ||
| 96 | Commands::Start => { | ||
| 97 | crate::daemon::start_daemon()?; | ||
| 98 | } | ||
| 99 | Commands::Stop => { | ||
| 100 | crate::daemon::stop_daemon()?; | ||
| 101 | } | ||
| 102 | Commands::Restart => { | ||
| 103 | crate::daemon::restart_daemon()?; | ||
| 104 | } | ||
| 105 | Commands::Play { query } => { | ||
| 106 | if let Some(q) = query { | ||
| 107 | crate::daemon::send_command(format!("play {}", q))?; | ||
| 108 | } else { | ||
| 109 | crate::daemon::send_command("play".to_string())?; | ||
| 110 | } | ||
| 111 | } | ||
| 112 | Commands::Pause => { | ||
| 113 | crate::daemon::send_command("pause".to_string())?; | ||
| 114 | } | ||
| 115 | Commands::Resume => { | ||
| 116 | crate::daemon::send_command("resume".to_string())?; | ||
| 117 | } | ||
| 118 | Commands::StopPlayback => { | ||
| 119 | crate::daemon::send_command("stop".to_string())?; | ||
| 120 | } | ||
| 121 | Commands::Next => { | ||
| 122 | crate::daemon::send_command("next".to_string())?; | ||
| 123 | } | ||
| 124 | Commands::Previous => { | ||
| 125 | crate::daemon::send_command("previous".to_string())?; | ||
| 126 | } | ||
| 127 | Commands::Volume { level } => { | ||
| 128 | if let Some(l) = level { | ||
| 129 | crate::daemon::send_command(format!("volume {}", l))?; | ||
| 130 | } else { | ||
| 131 | crate::daemon::send_command("volume".to_string())?; | ||
| 132 | } | ||
| 133 | } | ||
| 134 | Commands::Shuffle { mode } => { | ||
| 135 | if let Some(m) = mode { | ||
| 136 | crate::daemon::send_command(format!("shuffle {}", m))?; | ||
| 137 | } else { | ||
| 138 | crate::daemon::send_command("shuffle".to_string())?; | ||
| 139 | } | ||
| 140 | } | ||
| 141 | Commands::Queue { position, query } => match (position, query) { | ||
| 142 | (None, None) => { | ||
| 143 | crate::daemon::send_command("queue".to_string())?; | ||
| 144 | } | ||
| 145 | (pos, Some(q)) => { | ||
| 146 | let pos = pos.as_deref().unwrap_or("last"); | ||
| 147 | crate::daemon::send_command(format!("queue {} {}", pos, q))?; | ||
| 148 | } | ||
| 149 | _ => {} | ||
| 150 | }, | ||
| 151 | Commands::Status => { | ||
| 152 | crate::daemon::send_command("status".to_string())?; | ||
| 153 | } | ||
| 154 | Commands::Scan { force, paths } => { | ||
| 155 | let config = Config::load()?; | ||
| 156 | let db = Database::open(&config)?; | ||
| 157 | if let Some(custom_paths) = paths { | ||
| 158 | db.scan_paths(custom_paths, *force)?; | ||
| 159 | } else { | ||
| 160 | db.scan_paths(&config.music_paths, *force)?; | ||
| 161 | } | ||
| 162 | println!("Scan complete"); | ||
| 163 | } | ||
| 164 | Commands::Settings { command } => { | ||
| 165 | let mut config = Config::load()?; | ||
| 166 | match command { | ||
| 167 | SettingsCommands::Get { key } => { | ||
| 168 | println!("{}", config.get(key)?); | ||
| 169 | } | ||
| 170 | SettingsCommands::Set { key, value } => { | ||
| 171 | config.set(key, value)?; | ||
| 172 | config.save()?; | ||
| 173 | } | ||
| 174 | SettingsCommands::List => { | ||
| 175 | config.list(); | ||
| 176 | } | ||
| 177 | SettingsCommands::Unset { key } => { | ||
| 178 | config.unset(key)?; | ||
| 179 | config.save()?; | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | Ok(()) | ||
| 185 | } | ||
| 186 | } | ||
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cda59f4 --- /dev/null +++ b/src/config.rs | |||
| @@ -0,0 +1,180 @@ | |||
| 1 | use directories::ProjectDirs; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | use std::fs; | ||
| 4 | use std::path::PathBuf; | ||
| 5 | |||
| 6 | #[derive(Debug, Serialize, Deserialize, Default)] | ||
| 7 | pub struct Config { | ||
| 8 | #[serde(skip)] | ||
| 9 | pub config_path: PathBuf, | ||
| 10 | pub music_paths: Vec<PathBuf>, | ||
| 11 | pub scan_interval: Option<u64>, | ||
| 12 | pub socket_path: Option<PathBuf>, | ||
| 13 | pub lastfm_api_key: Option<String>, | ||
| 14 | pub lastfm_api_secret: Option<String>, | ||
| 15 | pub lastfm_session_key: Option<String>, | ||
| 16 | pub listenbrainz_token: Option<String>, | ||
| 17 | pub volume: Option<u8>, | ||
| 18 | pub shuffle: Option<String>, | ||
| 19 | } | ||
| 20 | |||
| 21 | impl Config { | ||
| 22 | pub fn load() -> Result<Self, Box<dyn std::error::Error>> { | ||
| 23 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer") | ||
| 24 | .ok_or("Failed to get project directories")?; | ||
| 25 | |||
| 26 | let config_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { | ||
| 27 | PathBuf::from(xdg).join("rsplayer") | ||
| 28 | } else { | ||
| 29 | proj_dirs.config_dir().to_path_buf() | ||
| 30 | }; | ||
| 31 | |||
| 32 | let config_path = config_dir.join("config.toml"); | ||
| 33 | |||
| 34 | if !config_path.exists() { | ||
| 35 | fs::create_dir_all(&config_dir)?; | ||
| 36 | let default = Self { | ||
| 37 | config_path: config_path.clone(), | ||
| 38 | music_paths: vec![dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music"))], | ||
| 39 | scan_interval: Some(3600), | ||
| 40 | socket_path: None, | ||
| 41 | ..Default::default() | ||
| 42 | }; | ||
| 43 | default.save()?; | ||
| 44 | return Ok(default); | ||
| 45 | } | ||
| 46 | |||
| 47 | let content = fs::read_to_string(&config_path)?; | ||
| 48 | let mut config: Self = toml::from_str(&content)?; | ||
| 49 | config.config_path = config_path; | ||
| 50 | Ok(config) | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> { | ||
| 54 | let content = toml::to_string_pretty(self)?; | ||
| 55 | if let Some(parent) = self.config_path.parent() { | ||
| 56 | fs::create_dir_all(parent)?; | ||
| 57 | } | ||
| 58 | fs::write(&self.config_path, content)?; | ||
| 59 | Ok(()) | ||
| 60 | } | ||
| 61 | |||
| 62 | pub fn get(&self, key: &str) -> Result<String, Box<dyn std::error::Error>> { | ||
| 63 | match key { | ||
| 64 | "music_paths" => Ok(self | ||
| 65 | .music_paths | ||
| 66 | .iter() | ||
| 67 | .map(|p| p.display().to_string()) | ||
| 68 | .collect::<Vec<_>>() | ||
| 69 | .join(", ")), | ||
| 70 | "scan_interval" => Ok(self | ||
| 71 | .scan_interval | ||
| 72 | .map(|i| i.to_string()) | ||
| 73 | .unwrap_or_else(|| "not set".to_string())), | ||
| 74 | "socket_path" => Ok(self | ||
| 75 | .socket_path | ||
| 76 | .as_ref() | ||
| 77 | .map(|p| p.display().to_string()) | ||
| 78 | .unwrap_or_else(|| "default".to_string())), | ||
| 79 | "lastfm_api_key" => Ok(self | ||
| 80 | .lastfm_api_key | ||
| 81 | .clone() | ||
| 82 | .unwrap_or_else(|| "not set".to_string())), | ||
| 83 | "lastfm_api_secret" => Ok(self | ||
| 84 | .lastfm_api_secret | ||
| 85 | .clone() | ||
| 86 | .unwrap_or_else(|| "not set".to_string())), | ||
| 87 | "lastfm_session_key" => Ok(self | ||
| 88 | .lastfm_session_key | ||
| 89 | .clone() | ||
| 90 | .unwrap_or_else(|| "not set".to_string())), | ||
| 91 | "listenbrainz_token" => Ok(self | ||
| 92 | .listenbrainz_token | ||
| 93 | .clone() | ||
| 94 | .unwrap_or_else(|| "not set".to_string())), | ||
| 95 | "volume" => Ok(self | ||
| 96 | .volume | ||
| 97 | .map(|v| v.to_string()) | ||
| 98 | .unwrap_or_else(|| "not set".to_string())), | ||
| 99 | "shuffle" => Ok(self.shuffle.clone().unwrap_or_else(|| "off".to_string())), | ||
| 100 | _ => Err(format!("Unknown setting: {}", key).into()), | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | pub fn set(&mut self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> { | ||
| 105 | match key { | ||
| 106 | "music_paths" => { | ||
| 107 | self.music_paths = value.split(',').map(|p| PathBuf::from(p.trim())).collect(); | ||
| 108 | } | ||
| 109 | "scan_interval" => { | ||
| 110 | self.scan_interval = Some(value.parse()?); | ||
| 111 | } | ||
| 112 | "socket_path" => { | ||
| 113 | self.socket_path = Some(PathBuf::from(value)); | ||
| 114 | } | ||
| 115 | "lastfm_api_key" => { | ||
| 116 | self.lastfm_api_key = Some(value.to_string()); | ||
| 117 | } | ||
| 118 | "lastfm_api_secret" => { | ||
| 119 | self.lastfm_api_secret = Some(value.to_string()); | ||
| 120 | } | ||
| 121 | "lastfm_session_key" => { | ||
| 122 | self.lastfm_session_key = Some(value.to_string()); | ||
| 123 | } | ||
| 124 | "listenbrainz_token" => { | ||
| 125 | self.listenbrainz_token = Some(value.to_string()); | ||
| 126 | } | ||
| 127 | "volume" => { | ||
| 128 | self.volume = Some(value.parse()?); | ||
| 129 | } | ||
| 130 | "shuffle" => { | ||
| 131 | if value == "on" || value == "all" || value == "album" { | ||
| 132 | self.shuffle = Some(value.to_string()); | ||
| 133 | } else if value == "off" || value == "none" { | ||
| 134 | self.shuffle = None; | ||
| 135 | } else { | ||
| 136 | return Err(format!("Invalid shuffle value: {}", value).into()); | ||
| 137 | } | ||
| 138 | } | ||
| 139 | _ => return Err(format!("Unknown setting: {}", key).into()), | ||
| 140 | } | ||
| 141 | Ok(()) | ||
| 142 | } | ||
| 143 | |||
| 144 | pub fn list(&self) { | ||
| 145 | println!("music_paths = {:?}", self.music_paths); | ||
| 146 | println!("scan_interval = {:?}", self.scan_interval); | ||
| 147 | println!("socket_path = {:?}", self.socket_path); | ||
| 148 | println!("lastfm_api_key = {:?}", self.lastfm_api_key); | ||
| 149 | println!("lastfm_api_secret = {:?}", self.lastfm_api_secret); | ||
| 150 | println!("lastfm_session_key = {:?}", self.lastfm_session_key); | ||
| 151 | println!("listenbrainz_token = {:?}", self.listenbrainz_token); | ||
| 152 | println!("volume = {:?}", self.volume); | ||
| 153 | println!("shuffle = {:?}", self.shuffle); | ||
| 154 | } | ||
| 155 | |||
| 156 | pub fn unset(&mut self, key: &str) -> Result<(), Box<dyn std::error::Error>> { | ||
| 157 | match key { | ||
| 158 | "music_paths" => self.music_paths.clear(), | ||
| 159 | "scan_interval" => self.scan_interval = None, | ||
| 160 | "socket_path" => self.socket_path = None, | ||
| 161 | "lastfm_api_key" => self.lastfm_api_key = None, | ||
| 162 | "lastfm_api_secret" => self.lastfm_api_secret = None, | ||
| 163 | "lastfm_session_key" => self.lastfm_session_key = None, | ||
| 164 | "listenbrainz_token" => self.listenbrainz_token = None, | ||
| 165 | "volume" => self.volume = None, | ||
| 166 | "shuffle" => self.shuffle = None, | ||
| 167 | _ => return Err(format!("Unknown setting: {}", key).into()), | ||
| 168 | } | ||
| 169 | Ok(()) | ||
| 170 | } | ||
| 171 | |||
| 172 | pub fn get_socket_path(&self) -> PathBuf { | ||
| 173 | self.socket_path.clone().unwrap_or_else(|| { | ||
| 174 | let runtime_dir = std::env::var("XDG_RUNTIME_DIR") | ||
| 175 | .map(PathBuf::from) | ||
| 176 | .unwrap_or_else(|_| PathBuf::from("/tmp")); | ||
| 177 | runtime_dir.join("rsplayer").join("daemon.sock") | ||
| 178 | }) | ||
| 179 | } | ||
| 180 | } | ||
diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..8ac3a89 --- /dev/null +++ b/src/daemon.rs | |||
| @@ -0,0 +1,574 @@ | |||
| 1 | use crate::audio::AudioEngine; | ||
| 2 | use crate::config::Config; | ||
| 3 | use std::io::{Read, Write}; | ||
| 4 | use std::os::unix::net::{UnixListener, UnixStream}; | ||
| 5 | use std::path::{Path, PathBuf}; | ||
| 6 | use std::sync::atomic::{AtomicBool, Ordering}; | ||
| 7 | use std::sync::{Arc, Mutex}; | ||
| 8 | use std::thread; | ||
| 9 | use std::time::Duration; | ||
| 10 | |||
| 11 | #[derive(Debug, Clone)] | ||
| 12 | enum ShuffleMode { | ||
| 13 | None, | ||
| 14 | All, | ||
| 15 | Album, | ||
| 16 | } | ||
| 17 | |||
| 18 | struct Queue { | ||
| 19 | tracks: Vec<PathBuf>, | ||
| 20 | position: usize, | ||
| 21 | shuffle: ShuffleMode, | ||
| 22 | shuffled_indices: Vec<usize>, | ||
| 23 | } | ||
| 24 | |||
| 25 | impl Queue { | ||
| 26 | fn new() -> Self { | ||
| 27 | Queue { | ||
| 28 | tracks: Vec::new(), | ||
| 29 | position: 0, | ||
| 30 | shuffle: ShuffleMode::None, | ||
| 31 | shuffled_indices: Vec::new(), | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | fn add_track(&mut self, path: PathBuf) { | ||
| 36 | self.tracks.push(path); | ||
| 37 | self.update_shuffle_indices(); | ||
| 38 | } | ||
| 39 | |||
| 40 | fn set_shuffle(&mut self, mode: ShuffleMode) { | ||
| 41 | self.shuffle = mode.clone(); | ||
| 42 | self.update_shuffle_indices(); | ||
| 43 | } | ||
| 44 | |||
| 45 | fn update_shuffle_indices(&mut self) { | ||
| 46 | self.shuffled_indices = (0..self.tracks.len()).collect(); | ||
| 47 | if let ShuffleMode::All = self.shuffle { | ||
| 48 | use rand::seq::SliceRandom; | ||
| 49 | let mut rng = rand::rng(); | ||
| 50 | self.shuffled_indices.shuffle(&mut rng); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | fn current(&self) -> Option<&PathBuf> { | ||
| 55 | if self.tracks.is_empty() { | ||
| 56 | None | ||
| 57 | } else if let ShuffleMode::All = self.shuffle { | ||
| 58 | self.shuffled_indices | ||
| 59 | .get(self.position) | ||
| 60 | .and_then(|&idx| self.tracks.get(idx)) | ||
| 61 | } else if let ShuffleMode::Album = self.shuffle { | ||
| 62 | // TODO: When database is ready, only shuffle tracks in same album/folder | ||
| 63 | // For now, behave like All | ||
| 64 | self.shuffled_indices | ||
| 65 | .get(self.position) | ||
| 66 | .and_then(|&idx| self.tracks.get(idx)) | ||
| 67 | } else { | ||
| 68 | self.tracks.get(self.position) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | fn next(&mut self) -> Option<PathBuf> { | ||
| 73 | let max_pos = self.tracks.len().saturating_sub(1); | ||
| 74 | if self.position < max_pos { | ||
| 75 | self.position += 1; | ||
| 76 | self.current().cloned() | ||
| 77 | } else { | ||
| 78 | None | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | fn previous(&mut self) -> Option<PathBuf> { | ||
| 83 | if self.position > 0 { | ||
| 84 | self.position -= 1; | ||
| 85 | self.current().cloned() | ||
| 86 | } else { | ||
| 87 | None | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | ||
| 93 | let config = Config::load()?; | ||
| 94 | let socket_path = config.get_socket_path(); | ||
| 95 | let pid_path = get_pid_path(&config); | ||
| 96 | |||
| 97 | // Check if daemon is already running | ||
| 98 | if let Ok(pid) = check_running_daemon(&pid_path) { | ||
| 99 | return Err(format!("Daemon already running with PID {}", pid).into()); | ||
| 100 | } | ||
| 101 | |||
| 102 | // Clean up stale socket file if it exists | ||
| 103 | if socket_path.exists() { | ||
| 104 | std::fs::remove_file(&socket_path).ok(); | ||
| 105 | } | ||
| 106 | |||
| 107 | // Create runtime directory | ||
| 108 | if let Some(parent) = socket_path.parent() { | ||
| 109 | std::fs::create_dir_all(parent)?; | ||
| 110 | } | ||
| 111 | if let Some(parent) = pid_path.parent() { | ||
| 112 | std::fs::create_dir_all(parent)?; | ||
| 113 | } | ||
| 114 | |||
| 115 | // Write PID file | ||
| 116 | let pid = std::process::id(); | ||
| 117 | std::fs::write(&pid_path, pid.to_string())?; | ||
| 118 | println!("Daemon started with PID {}", pid); | ||
| 119 | |||
| 120 | // Shared shutdown flag - can be triggered by signal or command | ||
| 121 | let shutdown_flag = Arc::new(AtomicBool::new(false)); | ||
| 122 | let shutdown_flag_signal = Arc::clone(&shutdown_flag); | ||
| 123 | |||
| 124 | // Handle SIGTERM and SIGINT | ||
| 125 | ctrlc::set_handler(move || { | ||
| 126 | println!("Received shutdown signal"); | ||
| 127 | shutdown_flag_signal.store(true, Ordering::SeqCst); | ||
| 128 | })?; | ||
| 129 | |||
| 130 | let listener = UnixListener::bind(&socket_path)?; | ||
| 131 | println!("Listening on {:?}", socket_path); | ||
| 132 | |||
| 133 | let audio_engine = Arc::new(AudioEngine::new()); | ||
| 134 | let queue = Arc::new(Mutex::new(Queue::new())); | ||
| 135 | |||
| 136 | // Load and apply saved settings | ||
| 137 | let config = Config::load().unwrap_or_default(); | ||
| 138 | if let Some(vol) = config.volume { | ||
| 139 | audio_engine.set_volume(vol as f32 / 100.0); | ||
| 140 | } | ||
| 141 | if let Some(ref shuffle_str) = config.shuffle { | ||
| 142 | if let Ok(mut q) = queue.lock() { | ||
| 143 | match shuffle_str.as_str() { | ||
| 144 | "on" | "all" => q.set_shuffle(ShuffleMode::All), | ||
| 145 | "album" => q.set_shuffle(ShuffleMode::Album), | ||
| 146 | _ => {} | ||
| 147 | } | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | // Accept connections until shutdown | ||
| 152 | listener.set_nonblocking(true)?; | ||
| 153 | loop { | ||
| 154 | // Check for shutdown signal | ||
| 155 | if shutdown_flag.load(Ordering::SeqCst) { | ||
| 156 | break; | ||
| 157 | } | ||
| 158 | |||
| 159 | // Check if track ended and play next | ||
| 160 | if audio_engine.poll_track_ended() { | ||
| 161 | if let Ok(mut q) = queue.lock() { | ||
| 162 | if let Some(next_track) = q.next() { | ||
| 163 | audio_engine.play(next_track); | ||
| 164 | } else { | ||
| 165 | audio_engine.stop(); | ||
| 166 | } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | match listener.accept() { | ||
| 171 | Ok((stream, _)) => { | ||
| 172 | let audio_clone = Arc::clone(&audio_engine); | ||
| 173 | let shutdown_flag_clone = Arc::clone(&shutdown_flag); | ||
| 174 | let queue_clone = Arc::clone(&queue); | ||
| 175 | thread::spawn(move || { | ||
| 176 | handle_client(stream, audio_clone, shutdown_flag_clone, queue_clone); | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { | ||
| 180 | thread::sleep(Duration::from_millis(50)); | ||
| 181 | continue; | ||
| 182 | } | ||
| 183 | Err(e) => eprintln!("Connection failed: {}", e), | ||
| 184 | } | ||
| 185 | |||
| 186 | thread::sleep(Duration::from_millis(50)); | ||
| 187 | } | ||
| 188 | |||
| 189 | // Cleanup | ||
| 190 | cleanup_daemon(&socket_path, &pid_path); | ||
| 191 | println!("Daemon stopped"); | ||
| 192 | Ok(()) | ||
| 193 | } | ||
| 194 | |||
| 195 | pub fn stop_daemon() -> Result<(), Box<dyn std::error::Error>> { | ||
| 196 | let config = Config::load()?; | ||
| 197 | let pid_path = get_pid_path(&config); | ||
| 198 | |||
| 199 | match check_running_daemon(&pid_path) { | ||
| 200 | Ok(pid) => { | ||
| 201 | // Try to send stop command first for graceful shutdown | ||
| 202 | if let Ok(()) = send_command_raw("stop_daemon".to_string()) { | ||
| 203 | // Wait for daemon to stop | ||
| 204 | for _ in 0..50 { | ||
| 205 | thread::sleep(Duration::from_millis(100)); | ||
| 206 | if !is_process_running(pid) { | ||
| 207 | break; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | // Force kill if still running | ||
| 213 | if is_process_running(pid) { | ||
| 214 | unsafe { | ||
| 215 | libc::kill(pid as i32, libc::SIGTERM); | ||
| 216 | } | ||
| 217 | thread::sleep(Duration::from_millis(500)); | ||
| 218 | |||
| 219 | // Force kill if still alive | ||
| 220 | if is_process_running(pid) { | ||
| 221 | unsafe { | ||
| 222 | libc::kill(pid as i32, libc::SIGKILL); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | // Clean up files | ||
| 228 | let socket_path = config.get_socket_path(); | ||
| 229 | std::fs::remove_file(&socket_path).ok(); | ||
| 230 | std::fs::remove_file(&pid_path).ok(); | ||
| 231 | |||
| 232 | println!("Daemon stopped"); | ||
| 233 | Ok(()) | ||
| 234 | } | ||
| 235 | Err(e) => Err(format!("Daemon not running: {}", e).into()), | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | pub fn restart_daemon() -> Result<(), Box<dyn std::error::Error>> { | ||
| 240 | // Stop existing daemon | ||
| 241 | if let Ok(()) = stop_daemon() { | ||
| 242 | // Wait a bit for cleanup | ||
| 243 | thread::sleep(Duration::from_millis(500)); | ||
| 244 | } | ||
| 245 | |||
| 246 | // Start new daemon in background | ||
| 247 | let current_exe = std::env::current_exe()?; | ||
| 248 | std::process::Command::new(current_exe) | ||
| 249 | .arg("start") | ||
| 250 | .spawn()?; | ||
| 251 | |||
| 252 | println!("Daemon restarted"); | ||
| 253 | Ok(()) | ||
| 254 | } | ||
| 255 | |||
| 256 | pub fn send_command(cmd: String) -> Result<(), Box<dyn std::error::Error>> { | ||
| 257 | send_command_raw(cmd)?; | ||
| 258 | Ok(()) | ||
| 259 | } | ||
| 260 | |||
| 261 | fn send_command_raw(cmd: String) -> Result<(), Box<dyn std::error::Error>> { | ||
| 262 | let config = Config::load()?; | ||
| 263 | let socket_path = config.get_socket_path(); | ||
| 264 | |||
| 265 | let mut stream = UnixStream::connect(socket_path)?; | ||
| 266 | stream.write_all(cmd.as_bytes())?; | ||
| 267 | |||
| 268 | let mut response = String::new(); | ||
| 269 | stream.read_to_string(&mut response)?; | ||
| 270 | print!("{}", response); | ||
| 271 | |||
| 272 | Ok(()) | ||
| 273 | } | ||
| 274 | |||
| 275 | fn handle_client( | ||
| 276 | mut stream: UnixStream, | ||
| 277 | audio: Arc<AudioEngine>, | ||
| 278 | shutdown_flag: Arc<AtomicBool>, | ||
| 279 | queue: Arc<Mutex<Queue>>, | ||
| 280 | ) { | ||
| 281 | let mut buffer = [0; 1024]; | ||
| 282 | if let Ok(size) = stream.read(&mut buffer) { | ||
| 283 | let cmd = String::from_utf8_lossy(&buffer[..size]); | ||
| 284 | let response = process_command(cmd.trim(), &audio, &shutdown_flag, &queue); | ||
| 285 | let _ = stream.write_all(response.as_bytes()); | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | fn process_command( | ||
| 290 | cmd: &str, | ||
| 291 | audio: &AudioEngine, | ||
| 292 | shutdown_flag: &Arc<AtomicBool>, | ||
| 293 | queue: &Arc<Mutex<Queue>>, | ||
| 294 | ) -> String { | ||
| 295 | match cmd { | ||
| 296 | "status" => { | ||
| 297 | if audio.is_playing() { | ||
| 298 | if let Some(track) = audio.current_track() { | ||
| 299 | format!("Playing\nTrack: {}\n", track.display()) | ||
| 300 | } else { | ||
| 301 | "Playing\n".to_string() | ||
| 302 | } | ||
| 303 | } else { | ||
| 304 | "Stopped\n".to_string() | ||
| 305 | } | ||
| 306 | } | ||
| 307 | "pause" => { | ||
| 308 | audio.pause(); | ||
| 309 | "Paused\n".to_string() | ||
| 310 | } | ||
| 311 | "resume" => { | ||
| 312 | audio.resume(); | ||
| 313 | "Resumed\n".to_string() | ||
| 314 | } | ||
| 315 | "stop" => { | ||
| 316 | audio.stop(); | ||
| 317 | // Clear queue on stop | ||
| 318 | if let Ok(mut q) = queue.lock() { | ||
| 319 | q.tracks.clear(); | ||
| 320 | q.position = 0; | ||
| 321 | } | ||
| 322 | "Stopped\n".to_string() | ||
| 323 | } | ||
| 324 | "stop_daemon" => { | ||
| 325 | // Signal the daemon to shut down | ||
| 326 | shutdown_flag.store(true, Ordering::SeqCst); | ||
| 327 | "Shutting down\n".to_string() | ||
| 328 | } | ||
| 329 | "next" => { | ||
| 330 | if let Ok(mut q) = queue.lock() { | ||
| 331 | if let Some(next_track) = q.next() { | ||
| 332 | audio.play(next_track); | ||
| 333 | "Playing next track\n".to_string() | ||
| 334 | } else { | ||
| 335 | audio.stop(); | ||
| 336 | "Queue empty, stopped\n".to_string() | ||
| 337 | } | ||
| 338 | } else { | ||
| 339 | "Error accessing queue\n".to_string() | ||
| 340 | } | ||
| 341 | } | ||
| 342 | "previous" => { | ||
| 343 | if let Ok(mut q) = queue.lock() { | ||
| 344 | if let Some(prev_track) = q.previous() { | ||
| 345 | audio.play(prev_track); | ||
| 346 | "Playing previous track\n".to_string() | ||
| 347 | } else { | ||
| 348 | "No previous track\n".to_string() | ||
| 349 | } | ||
| 350 | } else { | ||
| 351 | "Error accessing queue\n".to_string() | ||
| 352 | } | ||
| 353 | } | ||
| 354 | "shuffle" => { | ||
| 355 | if let Ok(q) = queue.lock() { | ||
| 356 | match q.shuffle { | ||
| 357 | ShuffleMode::None => "Shuffle: off\n".to_string(), | ||
| 358 | ShuffleMode::All => "Shuffle: on (all)\n".to_string(), | ||
| 359 | ShuffleMode::Album => "Shuffle: on (album)\n".to_string(), | ||
| 360 | } | ||
| 361 | } else { | ||
| 362 | "Error accessing queue\n".to_string() | ||
| 363 | } | ||
| 364 | } | ||
| 365 | _ if cmd.starts_with("shuffle ") => { | ||
| 366 | let mode_str = cmd[8..].trim(); | ||
| 367 | if let Ok(mut q) = queue.lock() { | ||
| 368 | let result = if mode_str == "on" || mode_str == "all" { | ||
| 369 | q.set_shuffle(ShuffleMode::All); | ||
| 370 | "Shuffle: on (all)\n".to_string() | ||
| 371 | } else if mode_str == "album" { | ||
| 372 | q.set_shuffle(ShuffleMode::Album); | ||
| 373 | "Shuffle: on (album)\n".to_string() | ||
| 374 | } else if mode_str == "off" || mode_str == "none" { | ||
| 375 | q.set_shuffle(ShuffleMode::None); | ||
| 376 | "Shuffle: off\n".to_string() | ||
| 377 | } else { | ||
| 378 | "Usage: shuffle [on|off|album]\n".to_string() | ||
| 379 | }; | ||
| 380 | // Save to config | ||
| 381 | if let Ok(mut config) = Config::load() { | ||
| 382 | if mode_str == "off" || mode_str == "none" { | ||
| 383 | config.shuffle = None; | ||
| 384 | } else { | ||
| 385 | config.shuffle = Some(mode_str.to_string()); | ||
| 386 | } | ||
| 387 | let _ = config.save(); | ||
| 388 | } | ||
| 389 | result | ||
| 390 | } else { | ||
| 391 | "Error accessing queue\n".to_string() | ||
| 392 | } | ||
| 393 | } | ||
| 394 | _ if cmd.starts_with("play ") => { | ||
| 395 | let path_str = &cmd[5..].trim(); | ||
| 396 | let path = std::path::PathBuf::from(path_str); | ||
| 397 | if path.is_file() { | ||
| 398 | // Add to queue and play | ||
| 399 | if let Ok(mut q) = queue.lock() { | ||
| 400 | q.add_track(path.clone()); | ||
| 401 | } | ||
| 402 | audio.play(path); | ||
| 403 | format!("Playing: {}\n", path_str) | ||
| 404 | } else if path.is_dir() { | ||
| 405 | // Find all audio files in directory | ||
| 406 | match find_audio_in_dir(&path) { | ||
| 407 | Some(files) if !files.is_empty() => { | ||
| 408 | if let Ok(mut q) = queue.lock() { | ||
| 409 | for file in &files { | ||
| 410 | q.add_track(file.clone()); | ||
| 411 | } | ||
| 412 | // If shuffle is on, shuffled_indices is already set | ||
| 413 | // Play the first track from the shuffled list | ||
| 414 | if let Some(track) = q.current().cloned() { | ||
| 415 | audio.play(track); | ||
| 416 | } | ||
| 417 | } | ||
| 418 | format!("Queued {} tracks from: {}\n", files.len(), path_str) | ||
| 419 | } | ||
| 420 | _ => format!("Error: No audio files found in '{}'\n", path_str), | ||
| 421 | } | ||
| 422 | } else { | ||
| 423 | format!("File not found: {}\n", path_str) | ||
| 424 | } | ||
| 425 | } | ||
| 426 | _ if cmd.starts_with("volume ") => { | ||
| 427 | if let Ok(level) = cmd[7..].trim().parse::<u8>() { | ||
| 428 | audio.set_volume(level as f32 / 100.0); | ||
| 429 | // Save to config | ||
| 430 | if let Ok(mut config) = Config::load() { | ||
| 431 | config.volume = Some(level); | ||
| 432 | let _ = config.save(); | ||
| 433 | } | ||
| 434 | format!("Volume set to {}\n", level) | ||
| 435 | } else { | ||
| 436 | "Usage: volume <0-100>\n".to_string() | ||
| 437 | } | ||
| 438 | } | ||
| 439 | _ if cmd.starts_with("shuffle ") => { | ||
| 440 | let mode_str = cmd[8..].trim(); | ||
| 441 | if let Ok(mut q) = queue.lock() { | ||
| 442 | if mode_str == "on" || mode_str == "all" { | ||
| 443 | q.set_shuffle(ShuffleMode::All); | ||
| 444 | "Shuffle: on\n".to_string() | ||
| 445 | } else if mode_str == "off" || mode_str == "none" { | ||
| 446 | q.set_shuffle(ShuffleMode::None); | ||
| 447 | "Shuffle: off\n".to_string() | ||
| 448 | } else { | ||
| 449 | "Usage: shuffle [on|off]\n".to_string() | ||
| 450 | } | ||
| 451 | } else { | ||
| 452 | "Error accessing queue\n".to_string() | ||
| 453 | } | ||
| 454 | } | ||
| 455 | _ if cmd.starts_with("queue") => { | ||
| 456 | if let Ok(q) = queue.lock() { | ||
| 457 | if q.tracks.is_empty() { | ||
| 458 | "Queue is empty\n".to_string() | ||
| 459 | } else { | ||
| 460 | let mut output = String::new(); | ||
| 461 | let current_position = q.position; | ||
| 462 | // Use shuffled_indices for display order if shuffle is on | ||
| 463 | let indices: Vec<usize> = if let ShuffleMode::None = q.shuffle { | ||
| 464 | (0..q.tracks.len()).collect() | ||
| 465 | } else { | ||
| 466 | q.shuffled_indices.clone() | ||
| 467 | }; | ||
| 468 | for (display_idx, &track_idx) in indices.iter().enumerate() { | ||
| 469 | if let Some(track) = q.tracks.get(track_idx) { | ||
| 470 | if display_idx == current_position { | ||
| 471 | output.push_str(&format!("> {} (playing)\n", track.display())); | ||
| 472 | } else { | ||
| 473 | output.push_str(&format!(" {}\n", track.display())); | ||
| 474 | } | ||
| 475 | } | ||
| 476 | } | ||
| 477 | // Use pager if output is long (more than 24 lines) | ||
| 478 | if output.lines().count() > 24 { | ||
| 479 | use std::process::{Command, Stdio}; | ||
| 480 | let pager = Command::new("less").arg("-R").stdin(Stdio::piped()).spawn(); | ||
| 481 | if let Ok(mut child) = pager { | ||
| 482 | use std::io::Write; | ||
| 483 | let _ = child.stdin.as_mut().unwrap().write_all(output.as_bytes()); | ||
| 484 | let _ = child.wait(); | ||
| 485 | "".to_string() | ||
| 486 | } else { | ||
| 487 | output | ||
| 488 | } | ||
| 489 | } else { | ||
| 490 | output | ||
| 491 | } | ||
| 492 | } | ||
| 493 | } else { | ||
| 494 | "Error accessing queue\n".to_string() | ||
| 495 | } | ||
| 496 | } | ||
| 497 | _ => "Unknown command\n".to_string(), | ||
| 498 | } | ||
| 499 | } | ||
| 500 | |||
| 501 | fn find_audio_in_dir(dir: &Path) -> Option<Vec<std::path::PathBuf>> { | ||
| 502 | use std::ffi::OsStr; | ||
| 503 | |||
| 504 | let mut results = Vec::new(); | ||
| 505 | |||
| 506 | if let Ok(entries) = std::fs::read_dir(dir) { | ||
| 507 | let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); | ||
| 508 | entries.sort_by_key(|e| e.file_name()); | ||
| 509 | |||
| 510 | for entry in entries { | ||
| 511 | let path = entry.path(); | ||
| 512 | if path.is_file() { | ||
| 513 | if let Some(ext) = path.extension().and_then(OsStr::to_str) { | ||
| 514 | if matches!( | ||
| 515 | ext.to_lowercase().as_str(), | ||
| 516 | "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" | ||
| 517 | ) { | ||
| 518 | results.push(path); | ||
| 519 | } | ||
| 520 | } | ||
| 521 | } else if path.is_dir() { | ||
| 522 | if let Some(mut found) = find_audio_in_dir(&path) { | ||
| 523 | results.append(&mut found); | ||
| 524 | } | ||
| 525 | } | ||
| 526 | } | ||
| 527 | } | ||
| 528 | |||
| 529 | if results.is_empty() { | ||
| 530 | None | ||
| 531 | } else { | ||
| 532 | Some(results) | ||
| 533 | } | ||
| 534 | } | ||
| 535 | |||
| 536 | /// Get the PID file path for the daemon | ||
| 537 | fn get_pid_path(_config: &Config) -> std::path::PathBuf { | ||
| 538 | let runtime_dir = std::env::var("XDG_RUNTIME_DIR") | ||
| 539 | .map(std::path::PathBuf::from) | ||
| 540 | .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); | ||
| 541 | runtime_dir.join("rsplayer").join("rsplayer.pid") | ||
| 542 | } | ||
| 543 | |||
| 544 | /// Check if a process with the given PID is running | ||
| 545 | fn is_process_running(pid: u32) -> bool { | ||
| 546 | unsafe { | ||
| 547 | // send signal 0 to check if process exists | ||
| 548 | libc::kill(pid as i32, 0) == 0 | ||
| 549 | } | ||
| 550 | } | ||
| 551 | |||
| 552 | /// Check if daemon is running by reading PID file | ||
| 553 | fn check_running_daemon(pid_path: &std::path::Path) -> Result<u32, Box<dyn std::error::Error>> { | ||
| 554 | if !pid_path.exists() { | ||
| 555 | return Err("PID file not found".into()); | ||
| 556 | } | ||
| 557 | |||
| 558 | let contents = std::fs::read_to_string(pid_path)?; | ||
| 559 | let pid = contents.trim().parse::<u32>()?; | ||
| 560 | |||
| 561 | if is_process_running(pid) { | ||
| 562 | Ok(pid) | ||
| 563 | } else { | ||
| 564 | // Stale PID file, clean it up | ||
| 565 | std::fs::remove_file(pid_path).ok(); | ||
| 566 | Err("Stale PID file, daemon not running".into()) | ||
| 567 | } | ||
| 568 | } | ||
| 569 | |||
| 570 | /// Clean up daemon resources (socket and PID file) | ||
| 571 | fn cleanup_daemon(socket_path: &std::path::Path, pid_path: &std::path::Path) { | ||
| 572 | std::fs::remove_file(socket_path).ok(); | ||
| 573 | std::fs::remove_file(pid_path).ok(); | ||
| 574 | } | ||
diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..9d1d5f2 --- /dev/null +++ b/src/database.rs | |||
| @@ -0,0 +1,259 @@ | |||
| 1 | use directories::ProjectDirs; | ||
| 2 | use rusqlite::{Connection, Result as SqlResult}; | ||
| 3 | use std::fs; | ||
| 4 | use std::path::{Path, PathBuf}; | ||
| 5 | use std::time::SystemTime; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone)] | ||
| 8 | #[allow(dead_code)] | ||
| 9 | pub struct Artist { | ||
| 10 | pub id: i64, | ||
| 11 | pub name: String, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Debug, Clone)] | ||
| 15 | #[allow(dead_code)] | ||
| 16 | pub struct Album { | ||
| 17 | pub id: i64, | ||
| 18 | pub artist_id: i64, | ||
| 19 | pub title: String, | ||
| 20 | } | ||
| 21 | |||
| 22 | #[derive(Debug, Clone)] | ||
| 23 | #[allow(dead_code)] | ||
| 24 | pub struct Track { | ||
| 25 | pub id: i64, | ||
| 26 | pub album_id: i64, | ||
| 27 | pub title: String, | ||
| 28 | pub path: PathBuf, | ||
| 29 | pub duration: Option<i64>, | ||
| 30 | pub track_number: Option<i32>, | ||
| 31 | pub artist_name: Option<String>, | ||
| 32 | pub album_title: Option<String>, | ||
| 33 | } | ||
| 34 | |||
| 35 | pub struct Database { | ||
| 36 | conn: Connection, | ||
| 37 | } | ||
| 38 | |||
| 39 | #[allow(dead_code)] | ||
| 40 | impl Database { | ||
| 41 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { | ||
| 42 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { | ||
| 43 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) | ||
| 44 | })?; | ||
| 45 | |||
| 46 | let data_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { | ||
| 47 | PathBuf::from(xdg).join("rsplayer") | ||
| 48 | } else { | ||
| 49 | proj_dirs.data_dir().to_path_buf() | ||
| 50 | }; | ||
| 51 | |||
| 52 | fs::create_dir_all(&data_dir).ok(); | ||
| 53 | let db_path = data_dir.join("library.db"); | ||
| 54 | |||
| 55 | let conn = Connection::open(&db_path)?; | ||
| 56 | let db = Database { conn }; | ||
| 57 | db.init_schema()?; | ||
| 58 | Ok(db) | ||
| 59 | } | ||
| 60 | |||
| 61 | fn init_schema(&self) -> SqlResult<()> { | ||
| 62 | self.conn.execute( | ||
| 63 | "CREATE TABLE IF NOT EXISTS artists ( | ||
| 64 | id INTEGER PRIMARY KEY, | ||
| 65 | name TEXT UNIQUE NOT NULL | ||
| 66 | )", | ||
| 67 | [], | ||
| 68 | )?; | ||
| 69 | |||
| 70 | self.conn.execute( | ||
| 71 | "CREATE TABLE IF NOT EXISTS albums ( | ||
| 72 | id INTEGER PRIMARY KEY, | ||
| 73 | artist_id INTEGER NOT NULL, | ||
| 74 | title TEXT NOT NULL, | ||
| 75 | FOREIGN KEY (artist_id) REFERENCES artists(id), | ||
| 76 | UNIQUE(artist_id, title) | ||
| 77 | )", | ||
| 78 | [], | ||
| 79 | )?; | ||
| 80 | |||
| 81 | self.conn.execute( | ||
| 82 | "CREATE TABLE IF NOT EXISTS tracks ( | ||
| 83 | id INTEGER PRIMARY KEY, | ||
| 84 | album_id INTEGER NOT NULL, | ||
| 85 | title TEXT NOT NULL, | ||
| 86 | path TEXT UNIQUE NOT NULL, | ||
| 87 | duration INTEGER, | ||
| 88 | track_number INTEGER, | ||
| 89 | last_scanned INTEGER, | ||
| 90 | FOREIGN KEY (album_id) REFERENCES albums(id) | ||
| 91 | )", | ||
| 92 | [], | ||
| 93 | )?; | ||
| 94 | |||
| 95 | Ok(()) | ||
| 96 | } | ||
| 97 | |||
| 98 | pub fn scan_paths(&self, paths: &[PathBuf], force: bool) -> SqlResult<()> { | ||
| 99 | for path in paths { | ||
| 100 | self.scan_directory(path, force)?; | ||
| 101 | } | ||
| 102 | Ok(()) | ||
| 103 | } | ||
| 104 | |||
| 105 | fn scan_directory(&self, dir: &Path, force: bool) -> SqlResult<()> { | ||
| 106 | if !dir.exists() { | ||
| 107 | return Ok(()); | ||
| 108 | } | ||
| 109 | |||
| 110 | let now = SystemTime::now() | ||
| 111 | .duration_since(SystemTime::UNIX_EPOCH) | ||
| 112 | .unwrap() | ||
| 113 | .as_secs() as i64; | ||
| 114 | |||
| 115 | self.scan_recursive(dir, now, force)?; | ||
| 116 | self.cleanup_old_tracks(now)?; | ||
| 117 | Ok(()) | ||
| 118 | } | ||
| 119 | |||
| 120 | fn scan_recursive(&self, dir: &Path, scan_time: i64, force: bool) -> SqlResult<()> { | ||
| 121 | let entries = fs::read_dir(dir).ok(); | ||
| 122 | if let Some(entries) = entries { | ||
| 123 | for entry in entries.flatten() { | ||
| 124 | let path = entry.path(); | ||
| 125 | if path.is_dir() { | ||
| 126 | self.scan_recursive(&path, scan_time, force)?; | ||
| 127 | } else if is_audio_file(&path) { | ||
| 128 | self.process_audio_file(&path, scan_time, force)?; | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | Ok(()) | ||
| 133 | } | ||
| 134 | |||
| 135 | fn process_audio_file(&self, path: &Path, scan_time: i64, force: bool) -> SqlResult<()> { | ||
| 136 | let path_str = path.to_string_lossy().to_string(); | ||
| 137 | |||
| 138 | if !force { | ||
| 139 | let count: i64 = self | ||
| 140 | .conn | ||
| 141 | .query_row( | ||
| 142 | "SELECT COUNT(*) FROM tracks WHERE path = ? AND last_scanned = ?", | ||
| 143 | rusqlite::params![path_str, scan_time], | ||
| 144 | |row| row.get(0), | ||
| 145 | ) | ||
| 146 | .unwrap_or(0); | ||
| 147 | if count > 0 { | ||
| 148 | return Ok(()); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | let metadata = extract_metadata(path); | ||
| 153 | let (artist_name, album_title, track_title, track_number, duration) = metadata; | ||
| 154 | let duration_i64 = duration.map(|d| d as i64); | ||
| 155 | |||
| 156 | let artist_id = self.get_or_create_artist(&artist_name)?; | ||
| 157 | let album_id = self.get_or_create_album(artist_id, &album_title)?; | ||
| 158 | |||
| 159 | self.conn.execute( | ||
| 160 | "INSERT OR REPLACE INTO tracks (album_id, title, path, duration, track_number, last_scanned) | ||
| 161 | VALUES (?, ?, ?, ?, ?, ?)", | ||
| 162 | rusqlite::params![album_id, track_title, path_str, duration_i64, track_number, scan_time], | ||
| 163 | )?; | ||
| 164 | |||
| 165 | Ok(()) | ||
| 166 | } | ||
| 167 | |||
| 168 | fn get_or_create_artist(&self, name: &str) -> SqlResult<i64> { | ||
| 169 | self.conn | ||
| 170 | .execute("INSERT OR IGNORE INTO artists (name) VALUES (?)", [name])?; | ||
| 171 | self.conn | ||
| 172 | .query_row("SELECT id FROM artists WHERE name = ?", [name], |row| { | ||
| 173 | row.get(0) | ||
| 174 | }) | ||
| 175 | } | ||
| 176 | |||
| 177 | fn get_or_create_album(&self, artist_id: i64, title: &str) -> SqlResult<i64> { | ||
| 178 | self.conn.execute( | ||
| 179 | "INSERT OR IGNORE INTO albums (artist_id, title) VALUES (?, ?)", | ||
| 180 | rusqlite::params![artist_id, title], | ||
| 181 | )?; | ||
| 182 | self.conn.query_row( | ||
| 183 | "SELECT id FROM albums WHERE artist_id = ? AND title = ?", | ||
| 184 | rusqlite::params![artist_id, title], | ||
| 185 | |row| row.get(0), | ||
| 186 | ) | ||
| 187 | } | ||
| 188 | |||
| 189 | fn cleanup_old_tracks(&self, scan_time: i64) -> SqlResult<()> { | ||
| 190 | self.conn | ||
| 191 | .execute("DELETE FROM tracks WHERE last_scanned < ?", [scan_time])?; | ||
| 192 | Ok(()) | ||
| 193 | } | ||
| 194 | |||
| 195 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | ||
| 196 | let mut stmt = self.conn.prepare( | ||
| 197 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 198 | FROM tracks t | ||
| 199 | JOIN albums a ON t.album_id = a.id | ||
| 200 | JOIN artists ar ON a.artist_id = ar.id | ||
| 201 | WHERE t.title LIKE ? OR a.title LIKE ? OR ar.name LIKE ? | ||
| 202 | ORDER BY ar.name, a.title, t.track_number", | ||
| 203 | )?; | ||
| 204 | |||
| 205 | let search = format!("%{}%", query); | ||
| 206 | let rows = stmt.query_map([&search, &search, &search], |row| { | ||
| 207 | Ok(Track { | ||
| 208 | id: row.get(0)?, | ||
| 209 | album_id: row.get(1)?, | ||
| 210 | title: row.get(2)?, | ||
| 211 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 212 | duration: row.get(4)?, | ||
| 213 | track_number: row.get(5)?, | ||
| 214 | artist_name: Some(row.get(6)?), | ||
| 215 | album_title: Some(row.get(7)?), | ||
| 216 | }) | ||
| 217 | })?; | ||
| 218 | |||
| 219 | rows.collect() | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | fn is_audio_file(path: &Path) -> bool { | ||
| 224 | path.extension() | ||
| 225 | .and_then(|ext| ext.to_str()) | ||
| 226 | .map(|ext| { | ||
| 227 | matches!( | ||
| 228 | ext.to_lowercase().as_str(), | ||
| 229 | "mp3" | "flac" | "ogg" | "m4a" | "wav" | "opus" | ||
| 230 | ) | ||
| 231 | }) | ||
| 232 | .unwrap_or(false) | ||
| 233 | } | ||
| 234 | |||
| 235 | fn extract_metadata(path: &Path) -> (String, String, String, Option<i32>, Option<u64>) { | ||
| 236 | let file_stem = path | ||
| 237 | .file_stem() | ||
| 238 | .and_then(|s| s.to_str()) | ||
| 239 | .unwrap_or("Unknown"); | ||
| 240 | let parent = path | ||
| 241 | .parent() | ||
| 242 | .and_then(|p| p.file_name()) | ||
| 243 | .and_then(|s| s.to_str()) | ||
| 244 | .unwrap_or("Unknown"); | ||
| 245 | let grandparent = path | ||
| 246 | .parent() | ||
| 247 | .and_then(|p| p.parent()) | ||
| 248 | .and_then(|p| p.file_name()) | ||
| 249 | .and_then(|s| s.to_str()) | ||
| 250 | .unwrap_or("Unknown"); | ||
| 251 | |||
| 252 | ( | ||
| 253 | grandparent.to_string(), | ||
| 254 | parent.to_string(), | ||
| 255 | file_stem.to_string(), | ||
| 256 | None, | ||
| 257 | None, | ||
| 258 | ) | ||
| 259 | } | ||
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2b88c44 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | mod audio; | ||
| 2 | mod cli; | ||
| 3 | mod config; | ||
| 4 | mod daemon; | ||
| 5 | mod database; | ||
| 6 | mod playback; | ||
| 7 | mod scrobbler; | ||
| 8 | |||
| 9 | use clap::Parser; | ||
| 10 | use cli::Cli; | ||
| 11 | |||
| 12 | fn main() { | ||
| 13 | let cli = Cli::parse(); | ||
| 14 | if let Err(e) = cli.run() { | ||
| 15 | eprintln!("Error: {}", e); | ||
| 16 | std::process::exit(1); | ||
| 17 | } | ||
| 18 | } | ||
diff --git a/src/playback.rs b/src/playback.rs new file mode 100644 index 0000000..76c0f44 --- /dev/null +++ b/src/playback.rs | |||
| @@ -0,0 +1,131 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | |||
| 3 | #[derive(Debug, Clone, PartialEq)] | ||
| 4 | #[allow(dead_code)] | ||
| 5 | pub enum PlaybackState { | ||
| 6 | Stopped, | ||
| 7 | Playing, | ||
| 8 | Paused, | ||
| 9 | } | ||
| 10 | |||
| 11 | #[derive(Debug, Clone)] | ||
| 12 | #[allow(dead_code)] | ||
| 13 | pub struct Track { | ||
| 14 | pub path: PathBuf, | ||
| 15 | pub title: String, | ||
| 16 | pub artist: String, | ||
| 17 | pub album: String, | ||
| 18 | pub duration: Option<u64>, | ||
| 19 | } | ||
| 20 | |||
| 21 | #[derive(Debug, Clone)] | ||
| 22 | #[allow(dead_code)] | ||
| 23 | pub enum ShuffleMode { | ||
| 24 | None, | ||
| 25 | All, | ||
| 26 | Album, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[allow(dead_code)] | ||
| 30 | pub struct Player { | ||
| 31 | state: PlaybackState, | ||
| 32 | current_track: Option<Track>, | ||
| 33 | queue: Vec<Track>, | ||
| 34 | volume: u8, | ||
| 35 | shuffle: ShuffleMode, | ||
| 36 | } | ||
| 37 | |||
| 38 | #[allow(dead_code)] | ||
| 39 | impl Player { | ||
| 40 | pub fn new() -> Self { | ||
| 41 | Player { | ||
| 42 | state: PlaybackState::Stopped, | ||
| 43 | current_track: None, | ||
| 44 | queue: Vec::new(), | ||
| 45 | volume: 70, | ||
| 46 | shuffle: ShuffleMode::None, | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | pub fn state(&self) -> &PlaybackState { | ||
| 51 | &self.state | ||
| 52 | } | ||
| 53 | |||
| 54 | pub fn volume(&self) -> u8 { | ||
| 55 | self.volume | ||
| 56 | } | ||
| 57 | |||
| 58 | pub fn play(&mut self) { | ||
| 59 | if self.state == PlaybackState::Paused { | ||
| 60 | self.resume(); | ||
| 61 | } else if self.current_track.is_none() && !self.queue.is_empty() { | ||
| 62 | self.current_track = Some(self.queue.remove(0)); | ||
| 63 | self.state = PlaybackState::Playing; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | pub fn play_track(&mut self, track: Track) { | ||
| 68 | self.current_track = Some(track); | ||
| 69 | self.state = PlaybackState::Playing; | ||
| 70 | } | ||
| 71 | |||
| 72 | pub fn pause(&mut self) { | ||
| 73 | if self.state == PlaybackState::Playing { | ||
| 74 | self.state = PlaybackState::Paused; | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn resume(&mut self) { | ||
| 79 | if self.state == PlaybackState::Paused { | ||
| 80 | self.state = PlaybackState::Playing; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | pub fn stop(&mut self) { | ||
| 85 | self.state = PlaybackState::Stopped; | ||
| 86 | self.current_track = None; | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn next(&mut self) { | ||
| 90 | if !self.queue.is_empty() { | ||
| 91 | self.current_track = Some(self.queue.remove(0)); | ||
| 92 | } else { | ||
| 93 | self.stop(); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | pub fn previous(&mut self) { | ||
| 98 | if (self.state == PlaybackState::Playing || self.state == PlaybackState::Paused) | ||
| 99 | && self.current_track.is_some() | ||
| 100 | { | ||
| 101 | self.state = PlaybackState::Playing; | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | pub fn set_volume(&mut self, level: u8) { | ||
| 106 | self.volume = level.min(100); | ||
| 107 | } | ||
| 108 | |||
| 109 | pub fn set_shuffle(&mut self, mode: ShuffleMode) { | ||
| 110 | self.shuffle = mode; | ||
| 111 | } | ||
| 112 | |||
| 113 | pub fn queue_track(&mut self, track: Track, position: &str) { | ||
| 114 | match position { | ||
| 115 | "now" => self.queue.insert(0, track), | ||
| 116 | "next" => { | ||
| 117 | let idx = 1.min(self.queue.len()); | ||
| 118 | self.queue.insert(idx, track); | ||
| 119 | } | ||
| 120 | _ => self.queue.push(track), | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | pub fn dequeue_next(&mut self) -> Option<Track> { | ||
| 125 | if !self.queue.is_empty() { | ||
| 126 | Some(self.queue.remove(0)) | ||
| 127 | } else { | ||
| 128 | None | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
diff --git a/src/scrobbler.rs b/src/scrobbler.rs new file mode 100644 index 0000000..09a7f55 --- /dev/null +++ b/src/scrobbler.rs | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | use reqwest::Client; | ||
| 2 | |||
| 3 | #[allow(dead_code)] | ||
| 4 | pub struct Scrobbler { | ||
| 5 | lastfm_api_key: Option<String>, | ||
| 6 | lastfm_api_secret: Option<String>, | ||
| 7 | lastfm_session_key: Option<String>, | ||
| 8 | listenbrainz_token: Option<String>, | ||
| 9 | client: Client, | ||
| 10 | } | ||
| 11 | |||
| 12 | #[allow(dead_code)] | ||
| 13 | impl Scrobbler { | ||
| 14 | pub fn new( | ||
| 15 | lastfm_api_key: Option<String>, | ||
| 16 | lastfm_api_secret: Option<String>, | ||
| 17 | lastfm_session_key: Option<String>, | ||
| 18 | listenbrainz_token: Option<String>, | ||
| 19 | ) -> Self { | ||
| 20 | Scrobbler { | ||
| 21 | lastfm_api_key, | ||
| 22 | lastfm_api_secret, | ||
| 23 | lastfm_session_key, | ||
| 24 | listenbrainz_token, | ||
| 25 | client: Client::new(), | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { | ||
| 30 | if let Some(api_key) = &self.lastfm_api_key { | ||
| 31 | let _ = self.send_lastfm_now_playing(api_key, artist, track, album); | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { | ||
| 36 | if let Some(api_key) = &self.lastfm_api_key { | ||
| 37 | let _ = self.send_lastfm_scrobble(api_key, artist, track, album, timestamp); | ||
| 38 | } | ||
| 39 | if let Some(token) = &self.listenbrainz_token { | ||
| 40 | let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | fn send_lastfm_now_playing( | ||
| 45 | &self, | ||
| 46 | _api_key: &str, | ||
| 47 | _artist: &str, | ||
| 48 | _track: &str, | ||
| 49 | _album: Option<&str>, | ||
| 50 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 51 | Ok(()) | ||
| 52 | } | ||
| 53 | |||
| 54 | fn send_lastfm_scrobble( | ||
| 55 | &self, | ||
| 56 | _api_key: &str, | ||
| 57 | _artist: &str, | ||
| 58 | _track: &str, | ||
| 59 | _album: Option<&str>, | ||
| 60 | _timestamp: u64, | ||
| 61 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 62 | Ok(()) | ||
| 63 | } | ||
| 64 | |||
| 65 | fn send_listenbrainz_scrobble( | ||
| 66 | &self, | ||
| 67 | _token: &str, | ||
| 68 | _artist: &str, | ||
| 69 | _track: &str, | ||
| 70 | _album: Option<&str>, | ||
| 71 | _timestamp: u64, | ||
| 72 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 73 | Ok(()) | ||
| 74 | } | ||
| 75 | } | ||
