aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.rs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-05 21:12:32 +0200
committersalmonik <raisersoft@proton.me>2026-05-05 21:12:32 +0200
commitbd29978b2a0d0d6def486fcdaa63bc8934d68045 (patch)
tree5df0efb17d350ae7f095eb9c0940b1bbf568de96 /src/daemon.rs
downloadrsplayer-bd29978b2a0d0d6def486fcdaa63bc8934d68045.tar.gz
rsplayer-bd29978b2a0d0d6def486fcdaa63bc8934d68045.zip
fix(daemon): robust auto-advance; ensure next track starts even if playback fails
Diffstat (limited to '')
-rw-r--r--src/daemon.rs574
1 files changed, 574 insertions, 0 deletions
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 @@
1use crate::audio::AudioEngine;
2use crate::config::Config;
3use std::io::{Read, Write};
4use std::os::unix::net::{UnixListener, UnixStream};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::thread;
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
12enum ShuffleMode {
13 None,
14 All,
15 Album,
16}
17
18struct Queue {
19 tracks: Vec<PathBuf>,
20 position: usize,
21 shuffle: ShuffleMode,
22 shuffled_indices: Vec<usize>,
23}
24
25impl 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
92pub 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
195pub 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
239pub 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
256pub fn send_command(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
257 send_command_raw(cmd)?;
258 Ok(())
259}
260
261fn 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
275fn 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
289fn 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
501fn 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
537fn 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
545fn 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
553fn 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)
571fn 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}