1use std::{
2 net::{IpAddr, SocketAddr},
3 num::{NonZeroU32, NonZeroU8},
4 path::PathBuf,
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::track::MediaType;
10
11pub mod config_extra;
13
14pub type MusicDirsOwned = Vec<PathBuf>;
15
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
17#[serde(default)] #[allow(clippy::module_name_repetitions)]
19pub struct ServerSettings {
20 pub com: ComSettings,
21 pub player: PlayerSettings,
22 pub podcast: PodcastSettings,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
26#[serde(default)] pub struct PodcastSettings {
28 pub concurrent_downloads_max: NonZeroU8,
31 pub max_download_retries: u8,
34 pub download_dir: PathBuf,
36}
37
38fn default_podcast_dir() -> PathBuf {
40 dirs::audio_dir().map_or_else(
41 || PathBuf::from(shellexpand::tilde("~/Music").as_ref()),
42 |mut v| {
43 v.push("podcast");
44 v
45 },
46 )
47}
48
49impl Default for PodcastSettings {
50 fn default() -> Self {
51 Self {
52 concurrent_downloads_max: NonZeroU8::new(3).unwrap(),
53 max_download_retries: 3,
54 download_dir: default_podcast_dir(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
61#[serde(untagged)]
62pub enum ScanDepth {
63 Limited(u32),
66 Unlimited,
68}
69
70const LONG_TRACK_TIME: u64 = 600; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
75#[serde(untagged)]
76pub enum SeekStep {
77 Both(NonZeroU32),
79 Depends {
81 short_tracks: NonZeroU32,
83 long_tracks: NonZeroU32,
85 },
86}
87
88impl SeekStep {
89 #[allow(clippy::missing_panics_doc)] #[must_use]
91 pub fn default_both() -> Self {
92 Self::Both(NonZeroU32::new(5).unwrap())
93 }
94
95 #[allow(clippy::missing_panics_doc)] #[must_use]
97 pub fn default_depends() -> Self {
98 Self::Depends {
99 short_tracks: NonZeroU32::new(5).unwrap(),
100 long_tracks: NonZeroU32::new(30).unwrap(),
101 }
102 }
103
104 #[must_use]
108 pub fn get_step(&self, track_len: u64) -> i64 {
109 match self {
110 SeekStep::Both(v) => v.get().into(),
111 SeekStep::Depends {
112 short_tracks,
113 long_tracks,
114 } => {
115 if track_len >= LONG_TRACK_TIME {
116 long_tracks.get().into()
117 } else {
118 short_tracks.get().into()
119 }
120 }
121 }
122 }
123}
124
125impl Default for SeekStep {
126 fn default() -> Self {
127 Self::default_depends()
128 }
129}
130
131#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
132#[serde(rename_all = "lowercase")]
133pub enum PositionYesNoLower {
134 Yes,
136 No,
138}
139
140const DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC: u64 = 3;
142
143const DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST: u64 = 10;
145
146#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
148#[serde(untagged)]
149pub enum PositionYesNo {
150 Simple(PositionYesNoLower),
152 YesTime(u64),
154}
155
156impl PositionYesNo {
157 #[must_use]
159 pub fn get_time(&self, media_type: MediaType) -> Option<u64> {
160 match self {
161 PositionYesNo::Simple(v) => match v {
162 PositionYesNoLower::Yes => match media_type {
163 MediaType::Music => Some(DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC),
164 MediaType::Podcast => Some(DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST),
165 MediaType::LiveRadio => None,
166 },
167 PositionYesNoLower::No => None,
168 },
169 PositionYesNo::YesTime(v) => Some(*v),
170 }
171 }
172
173 #[must_use]
175 pub fn is_enabled(&self) -> bool {
176 match self {
177 PositionYesNo::Simple(v) => *v == PositionYesNoLower::Yes,
178 PositionYesNo::YesTime(_) => true,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
184#[serde(untagged)]
185pub enum RememberLastPosition {
186 All(PositionYesNo),
188 Depends {
190 music: PositionYesNo,
191 podcast: PositionYesNo,
192 },
193}
194
195impl RememberLastPosition {
196 #[must_use]
198 pub fn get_time(&self, media_type: MediaType) -> Option<u64> {
199 match self {
200 RememberLastPosition::All(v) => v.get_time(media_type),
201 RememberLastPosition::Depends { music, podcast } => match media_type {
202 MediaType::Music => music.get_time(media_type),
203 MediaType::Podcast => podcast.get_time(media_type),
204 MediaType::LiveRadio => None,
205 },
206 }
207 }
208
209 #[allow(clippy::needless_pass_by_value)] #[must_use]
214 pub fn is_enabled_for(&self, media_type: MediaType) -> bool {
215 match self {
216 RememberLastPosition::All(v) => v.is_enabled(),
217 RememberLastPosition::Depends { music, podcast } => match media_type {
218 MediaType::Music => music.is_enabled(),
219 MediaType::Podcast => podcast.is_enabled(),
220 MediaType::LiveRadio => false,
222 },
223 }
224 }
225}
226
227impl Default for RememberLastPosition {
228 fn default() -> Self {
229 Self::Depends {
230 music: PositionYesNo::Simple(PositionYesNoLower::No),
231 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
232 }
233 }
234}
235
236#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
237#[serde(default)] pub struct PlayerSettings {
239 pub music_dirs: MusicDirsOwned,
241 pub library_scan_depth: ScanDepth,
245 pub remember_position: RememberLastPosition,
247
248 pub loop_mode: LoopMode,
250 pub volume: u16,
252 pub speed: i32,
257 pub gapless: bool,
259 pub seek_step: SeekStep,
261
262 pub use_mediacontrols: bool,
264 pub set_discord_status: bool,
266
267 pub random_track_quantity: NonZeroU32,
269 pub random_album_min_quantity: NonZeroU32,
271}
272
273fn default_music_dirs() -> MusicDirsOwned {
275 Vec::from([
276 dirs::audio_dir().unwrap_or_else(|| PathBuf::from(shellexpand::tilde("~/Music").as_ref()))
277 ])
278}
279
280impl Default for PlayerSettings {
281 fn default() -> Self {
282 Self {
283 music_dirs: default_music_dirs(),
284 library_scan_depth: ScanDepth::Limited(10),
285 remember_position: RememberLastPosition::default(),
286
287 loop_mode: LoopMode::default(),
288 volume: 30,
290 speed: 10,
291 gapless: true,
292 seek_step: SeekStep::default(),
293
294 use_mediacontrols: true,
295 set_discord_status: true,
296
297 random_track_quantity: NonZeroU32::new(20).unwrap(),
298 random_album_min_quantity: NonZeroU32::new(5).unwrap(),
299 }
300 }
301}
302
303#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
305#[serde(rename_all = "lowercase")]
306#[repr(u8)]
307pub enum LoopMode {
308 Single = 0,
310 #[default]
312 Playlist = 1,
313 Random = 2,
315}
316
317impl LoopMode {
318 #[must_use]
319 pub fn display(self, display_symbol: bool) -> &'static str {
320 if display_symbol {
321 match self {
322 Self::Single => "🔂",
323 Self::Playlist => "🔁",
324 Self::Random => "🔀",
325 }
326 } else {
327 match self {
328 Self::Single => "single",
329 Self::Playlist => "playlist",
330 Self::Random => "random",
331 }
332 }
333 }
334
335 #[must_use]
337 pub fn discriminant(&self) -> u8 {
338 (*self) as u8
339 }
340
341 #[must_use]
343 pub fn tryfrom_discriminant(num: u8) -> Option<Self> {
344 Some(match num {
345 0 => Self::Single,
346 1 => Self::Playlist,
347 2 => Self::Random,
348 _ => return None,
349 })
350 }
351}
352
353#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
355pub struct ComSettings {
358 pub port: u16,
360 pub address: IpAddr,
362}
363
364impl Default for ComSettings {
365 fn default() -> Self {
366 Self {
367 port: 50101,
368 address: "::1".parse().unwrap(),
369 }
370 }
371}
372
373impl From<ComSettings> for SocketAddr {
374 fn from(value: ComSettings) -> Self {
375 Self::new(value.address, value.port)
376 }
377}
378
379mod v1_interop {
380 use std::num::TryFromIntError;
381
382 use super::{
383 ComSettings, LoopMode, NonZeroU32, NonZeroU8, PlayerSettings, PodcastSettings,
384 PositionYesNo, PositionYesNoLower, RememberLastPosition, ScanDepth, SeekStep,
385 ServerSettings,
386 };
387 use crate::config::v1;
388
389 impl From<v1::Loop> for LoopMode {
390 fn from(value: v1::Loop) -> Self {
391 match value {
392 v1::Loop::Single => Self::Single,
393 v1::Loop::Playlist => Self::Playlist,
394 v1::Loop::Random => Self::Random,
395 }
396 }
397 }
398
399 impl From<v1::SeekStep> for SeekStep {
400 fn from(value: v1::SeekStep) -> Self {
401 match value {
402 v1::SeekStep::Short => Self::Both(NonZeroU32::new(5).unwrap()),
403 v1::SeekStep::Long => Self::Both(NonZeroU32::new(30).unwrap()),
404 v1::SeekStep::Auto => Self::Depends {
405 short_tracks: NonZeroU32::new(5).unwrap(),
406 long_tracks: NonZeroU32::new(30).unwrap(),
407 },
408 }
409 }
410 }
411
412 impl From<v1::LastPosition> for RememberLastPosition {
413 fn from(value: v1::LastPosition) -> Self {
414 match value {
415 v1::LastPosition::Yes => Self::All(PositionYesNo::Simple(PositionYesNoLower::Yes)),
416 v1::LastPosition::No => Self::All(PositionYesNo::Simple(PositionYesNoLower::No)),
417 v1::LastPosition::Auto => Self::Depends {
419 music: PositionYesNo::Simple(PositionYesNoLower::No),
420 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
421 },
422 }
423 }
424 }
425
426 #[derive(Debug, Clone, PartialEq, thiserror::Error)]
428 pub enum ServerSettingsConvertError {
429 #[error("Zero value where expecting a non-zero value. old-config key: '{old_key}', new-config key: '{new_key}', error: {source}")]
431 ZeroValue {
432 old_key: &'static str,
433 new_key: &'static str,
434 #[source]
435 source: TryFromIntError,
436 },
437 }
438
439 impl TryFrom<v1::Settings> for ServerSettings {
440 type Error = ServerSettingsConvertError;
441
442 #[allow(clippy::cast_possible_truncation)] fn try_from(value: v1::Settings) -> Result<Self, Self::Error> {
444 let com_settings = ComSettings {
445 port: value.player_port,
446 address: value.player_interface,
447 };
448
449 let podcast_settings = PodcastSettings {
450 concurrent_downloads_max: NonZeroU8::try_from(
451 value
452 .podcast_simultanious_download
453 .clamp(0, u8::MAX as usize) as u8,
454 )
455 .map_err(|err| ServerSettingsConvertError::ZeroValue {
456 old_key: "podcast_simultanious_download",
457 new_key: "podcast.concurrent_downloads_max",
458 source: err,
459 })?,
460 max_download_retries: value.podcast_max_retries.clamp(0, u8::MAX as usize) as u8,
461 download_dir: value.podcast_dir,
462 };
463
464 let player_settings = PlayerSettings {
465 music_dirs: value.music_dir,
466 library_scan_depth: ScanDepth::Limited(10),
469 remember_position: value.player_remember_last_played_position.into(),
470 loop_mode: value.player_loop_mode.into(),
471 volume: value.player_volume,
472 speed: value.player_speed,
473 gapless: value.player_gapless,
474 seek_step: value.player_seek_step.into(),
475
476 use_mediacontrols: value.player_use_mpris,
477 set_discord_status: value.player_use_discord,
478
479 random_track_quantity: NonZeroU32::try_from(
480 value.playlist_select_random_track_quantity,
481 )
482 .map_err(|err| ServerSettingsConvertError::ZeroValue {
483 old_key: "playlist_select_random_track_quantity",
484 new_key: "player.random_track_quantity",
485 source: err,
486 })?,
487 random_album_min_quantity: NonZeroU32::try_from(
488 value.playlist_select_random_album_quantity,
489 )
490 .map_err(|err| ServerSettingsConvertError::ZeroValue {
491 old_key: "playlist_select_random_album_quantity",
492 new_key: "player.random_album_min_quantity",
493 source: err,
494 })?,
495 };
496
497 Ok(Self {
498 com: com_settings,
499 player: player_settings,
500 podcast: podcast_settings,
501 })
502 }
503 }
504
505 #[cfg(test)]
506 mod tests {
507 use pretty_assertions::assert_eq;
508 use std::path::PathBuf;
509
510 use super::*;
511
512 #[test]
513 fn should_convert_default_without_error() {
514 let converted: ServerSettings = v1::Settings::default().try_into().unwrap();
515 assert!(converted.podcast.download_dir.components().count() > 0);
516 let podcast_settings = {
517 let mut set = converted.podcast;
518 set.download_dir = PathBuf::new();
520 set
521 };
522
523 assert_eq!(
524 podcast_settings,
525 PodcastSettings {
526 concurrent_downloads_max: NonZeroU8::new(3).unwrap(),
527 max_download_retries: 3,
528 download_dir: PathBuf::new()
529 }
530 );
531
532 assert_eq!(
533 converted.com,
534 ComSettings {
535 port: 50101,
536 address: "::1".parse().unwrap()
537 }
538 );
539
540 assert!(!converted.player.music_dirs.is_empty());
541
542 let player_settings = {
543 let mut set = converted.player;
544 set.music_dirs.clear();
546 set
547 };
548
549 assert_eq!(
550 player_settings,
551 PlayerSettings {
552 music_dirs: Vec::new(),
553 library_scan_depth: ScanDepth::Limited(10),
554 remember_position: RememberLastPosition::Depends {
555 music: PositionYesNo::Simple(PositionYesNoLower::No),
556 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
557 },
558 loop_mode: LoopMode::Random,
559 volume: 70,
560 speed: 10,
561 gapless: true,
562 seek_step: SeekStep::Depends {
563 short_tracks: NonZeroU32::new(5).unwrap(),
564 long_tracks: NonZeroU32::new(30).unwrap(),
565 },
566 use_mediacontrols: true,
567 set_discord_status: true,
568 random_track_quantity: NonZeroU32::new(20).unwrap(),
569 random_album_min_quantity: NonZeroU32::new(5).unwrap(),
570 }
571 );
572 }
573 }
574}