aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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/audio.rs418
-rw-r--r--src/cli.rs186
-rw-r--r--src/config.rs180
-rw-r--r--src/daemon.rs574
-rw-r--r--src/database.rs259
-rw-r--r--src/main.rs18
-rw-r--r--src/playback.rs131
-rw-r--r--src/scrobbler.rs75
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 @@
1use std::path::PathBuf;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::mpsc::{self, Receiver, Sender};
4use std::sync::{Arc, Mutex};
5use std::thread;
6
7#[allow(dead_code)]
8pub enum AudioCommand {
9 Play(PathBuf),
10 Pause,
11 Resume,
12 Stop,
13 SetVolume(f32),
14}
15
16pub 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
23impl 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(&current_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
91struct 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
101fn 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
129fn 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
377fn 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 @@
1use crate::config::Config;
2use crate::database::Database;
3use clap::{Parser, Subcommand};
4use std::path::PathBuf;
5
6#[derive(Parser)]
7#[command(name = "rsp")]
8#[command(about = "Rust music player daemon", long_about = None)]
9pub struct Cli {
10 #[command(subcommand)]
11 pub command: Commands,
12}
13
14#[derive(Subcommand)]
15pub 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)]
71pub 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
93impl 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 @@
1use directories::ProjectDirs;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Serialize, Deserialize, Default)]
7pub 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
21impl 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 @@
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}
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 @@
1use directories::ProjectDirs;
2use rusqlite::{Connection, Result as SqlResult};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7#[derive(Debug, Clone)]
8#[allow(dead_code)]
9pub struct Artist {
10 pub id: i64,
11 pub name: String,
12}
13
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub 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)]
24pub 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
35pub struct Database {
36 conn: Connection,
37}
38
39#[allow(dead_code)]
40impl 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
223fn 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
235fn 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 @@
1mod audio;
2mod cli;
3mod config;
4mod daemon;
5mod database;
6mod playback;
7mod scrobbler;
8
9use clap::Parser;
10use cli::Cli;
11
12fn 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 @@
1use std::path::PathBuf;
2
3#[derive(Debug, Clone, PartialEq)]
4#[allow(dead_code)]
5pub enum PlaybackState {
6 Stopped,
7 Playing,
8 Paused,
9}
10
11#[derive(Debug, Clone)]
12#[allow(dead_code)]
13pub 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)]
23pub enum ShuffleMode {
24 None,
25 All,
26 Album,
27}
28
29#[allow(dead_code)]
30pub 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)]
39impl 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 @@
1use reqwest::Client;
2
3#[allow(dead_code)]
4pub 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)]
13impl 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}