1use std::{
2 fmt::Display,
3 net::{IpAddr, SocketAddr},
4 num::{NonZeroU32, NonZeroU8},
5 path::PathBuf,
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::track::MediaTypesSimple;
11use backends::BackendSettings;
12
13pub mod backends;
14pub mod config_extra;
16
17pub type MusicDirsOwned = Vec<PathBuf>;
18
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
20#[serde(default)] #[allow(clippy::module_name_repetitions)]
22pub struct ServerSettings {
23 pub com: ComSettings,
24 pub player: PlayerSettings,
25 pub podcast: PodcastSettings,
26 pub backends: BackendSettings,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
30#[serde(default)] pub struct PodcastSettings {
32 pub concurrent_downloads_max: NonZeroU8,
35 pub max_download_retries: u8,
38 pub download_dir: PathBuf,
40}
41
42fn default_podcast_dir() -> PathBuf {
44 dirs::audio_dir().map_or_else(
45 || PathBuf::from(shellexpand::tilde("~/Music").as_ref()),
46 |mut v| {
47 v.push("podcast");
48 v
49 },
50 )
51}
52
53impl Default for PodcastSettings {
54 fn default() -> Self {
55 Self {
56 concurrent_downloads_max: NonZeroU8::new(3).unwrap(),
57 max_download_retries: 3,
58 download_dir: default_podcast_dir(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
65#[serde(untagged)]
66pub enum ScanDepth {
67 Limited(u32),
70 Unlimited,
72}
73
74const LONG_TRACK_TIME: u64 = 600; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
79#[serde(untagged)]
80pub enum SeekStep {
81 Both(NonZeroU32),
83 Depends {
85 short_tracks: NonZeroU32,
87 long_tracks: NonZeroU32,
89 },
90}
91
92impl SeekStep {
93 #[allow(clippy::missing_panics_doc)] #[must_use]
95 pub fn default_both() -> Self {
96 Self::Both(NonZeroU32::new(5).unwrap())
97 }
98
99 #[allow(clippy::missing_panics_doc)] #[must_use]
101 pub fn default_depends() -> Self {
102 Self::Depends {
103 short_tracks: NonZeroU32::new(5).unwrap(),
104 long_tracks: NonZeroU32::new(30).unwrap(),
105 }
106 }
107
108 #[must_use]
112 pub fn get_step(&self, track_len: u64) -> i64 {
113 match self {
114 SeekStep::Both(v) => v.get().into(),
115 SeekStep::Depends {
116 short_tracks,
117 long_tracks,
118 } => {
119 if track_len >= LONG_TRACK_TIME {
120 long_tracks.get().into()
121 } else {
122 short_tracks.get().into()
123 }
124 }
125 }
126 }
127}
128
129impl Default for SeekStep {
130 fn default() -> Self {
131 Self::default_depends()
132 }
133}
134
135#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
136#[serde(rename_all = "lowercase")]
137pub enum PositionYesNoLower {
138 Yes,
140 No,
142}
143
144const DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC: u64 = 3;
146
147const DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST: u64 = 10;
149
150#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
152#[serde(untagged)]
153pub enum PositionYesNo {
154 Simple(PositionYesNoLower),
156 YesTime(u64),
158}
159
160impl PositionYesNo {
161 #[must_use]
163 pub fn get_time(&self, media_type: MediaTypesSimple) -> Option<u64> {
164 match self {
165 PositionYesNo::Simple(v) => match v {
166 PositionYesNoLower::Yes => match media_type {
167 MediaTypesSimple::Music => Some(DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC),
168 MediaTypesSimple::Podcast => Some(DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST),
169 MediaTypesSimple::LiveRadio => None,
170 },
171 PositionYesNoLower::No => None,
172 },
173 PositionYesNo::YesTime(v) => Some(*v),
174 }
175 }
176
177 #[must_use]
179 pub fn is_enabled(&self) -> bool {
180 match self {
181 PositionYesNo::Simple(v) => *v == PositionYesNoLower::Yes,
182 PositionYesNo::YesTime(_) => true,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
188#[serde(untagged)]
189pub enum RememberLastPosition {
190 All(PositionYesNo),
192 Depends {
194 music: PositionYesNo,
195 podcast: PositionYesNo,
196 },
197}
198
199impl RememberLastPosition {
200 #[must_use]
202 pub fn get_time(&self, media_type: MediaTypesSimple) -> Option<u64> {
203 match self {
204 RememberLastPosition::All(v) => v.get_time(media_type),
205 RememberLastPosition::Depends { music, podcast } => match media_type {
206 MediaTypesSimple::Music => music.get_time(media_type),
207 MediaTypesSimple::Podcast => podcast.get_time(media_type),
208 MediaTypesSimple::LiveRadio => None,
209 },
210 }
211 }
212
213 #[allow(clippy::needless_pass_by_value)] #[must_use]
218 pub fn is_enabled_for(&self, media_type: MediaTypesSimple) -> bool {
219 match self {
220 RememberLastPosition::All(v) => v.is_enabled(),
221 RememberLastPosition::Depends { music, podcast } => match media_type {
222 MediaTypesSimple::Music => music.is_enabled(),
223 MediaTypesSimple::Podcast => podcast.is_enabled(),
224 MediaTypesSimple::LiveRadio => false,
226 },
227 }
228 }
229}
230
231impl Default for RememberLastPosition {
232 fn default() -> Self {
233 Self::Depends {
234 music: PositionYesNo::Simple(PositionYesNoLower::No),
235 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
236 }
237 }
238}
239
240#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
241#[serde(default)] pub struct PlayerSettings {
243 pub music_dirs: MusicDirsOwned,
245 pub library_scan_depth: ScanDepth,
249 pub remember_position: RememberLastPosition,
251
252 pub loop_mode: LoopMode,
254 pub volume: u16,
256 pub speed: i32,
261 pub gapless: bool,
263 pub seek_step: SeekStep,
265
266 pub use_mediacontrols: bool,
268 pub set_discord_status: bool,
270
271 pub random_track_quantity: NonZeroU32,
273 pub random_album_min_quantity: NonZeroU32,
275
276 pub backend: Backend,
278}
279
280fn default_music_dirs() -> MusicDirsOwned {
282 Vec::from([
283 dirs::audio_dir().unwrap_or_else(|| PathBuf::from(shellexpand::tilde("~/Music").as_ref()))
284 ])
285}
286
287impl Default for PlayerSettings {
288 fn default() -> Self {
289 Self {
290 music_dirs: default_music_dirs(),
291 library_scan_depth: ScanDepth::Limited(10),
292 remember_position: RememberLastPosition::default(),
293
294 loop_mode: LoopMode::default(),
295 volume: 30,
297 speed: 10,
298 gapless: true,
299 seek_step: SeekStep::default(),
300
301 use_mediacontrols: true,
302 set_discord_status: true,
303
304 random_track_quantity: NonZeroU32::new(20).unwrap(),
305 random_album_min_quantity: NonZeroU32::new(5).unwrap(),
306
307 backend: Backend::default(),
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
313#[serde(rename_all = "lowercase")]
314pub enum Backend {
315 #[serde(rename = "gst")]
316 #[serde(alias = "gstreamer")]
317 Gstreamer,
318 Mpv,
319 #[default]
320 Rusty,
321}
322
323impl Backend {
324 #[must_use]
325 pub fn as_str(self) -> &'static str {
326 match self {
327 Backend::Gstreamer => "gst",
328 Backend::Mpv => "mpv",
329 Backend::Rusty => "rusty",
330 }
331 }
332}
333
334impl Display for Backend {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 write!(f, "{}", self.as_str())
337 }
338}
339
340#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
342#[serde(rename_all = "lowercase")]
343#[repr(u8)]
344pub enum LoopMode {
345 Single = 0,
347 #[default]
349 Playlist = 1,
350 Random = 2,
352}
353
354impl LoopMode {
355 #[must_use]
356 pub fn display(self, display_symbol: bool) -> &'static str {
357 if display_symbol {
358 match self {
359 Self::Single => "🔂",
360 Self::Playlist => "🔁",
361 Self::Random => "🔀",
362 }
363 } else {
364 match self {
365 Self::Single => "single",
366 Self::Playlist => "playlist",
367 Self::Random => "random",
368 }
369 }
370 }
371
372 #[must_use]
374 pub fn discriminant(&self) -> u8 {
375 (*self) as u8
376 }
377
378 #[must_use]
380 pub fn tryfrom_discriminant(num: u8) -> Option<Self> {
381 Some(match num {
382 0 => Self::Single,
383 1 => Self::Playlist,
384 2 => Self::Random,
385 _ => return None,
386 })
387 }
388}
389
390#[derive(Debug, Clone, PartialEq, thiserror::Error)]
392pub enum ComProtocolParseError {
393 #[error("Expected \"uds\" or \"http\", found \"{0}\"")]
394 UnknownValue(String),
395}
396
397#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
399#[serde(try_from = "String")]
400#[serde(into = "String")]
401pub enum ComProtocol {
402 HTTP,
403 UDS,
405}
406
407impl TryFrom<String> for ComProtocol {
408 type Error = ComProtocolParseError;
409
410 fn try_from(value: String) -> Result<Self, Self::Error> {
411 Self::try_from(value.as_str())
412 }
413}
414
415impl TryFrom<&str> for ComProtocol {
416 type Error = ComProtocolParseError;
417
418 fn try_from(value: &str) -> Result<Self, Self::Error> {
419 let lowercase = value.to_ascii_lowercase();
420 Ok(match lowercase.as_str() {
421 "http" => Self::HTTP,
422 "uds" => Self::UDS,
423 _ => return Err(ComProtocolParseError::UnknownValue(lowercase)),
424 })
425 }
426}
427
428impl From<ComProtocol> for String {
429 fn from(val: ComProtocol) -> Self {
430 match val {
431 ComProtocol::HTTP => "http",
432 ComProtocol::UDS => "uds",
433 }
434 .to_string()
435 }
436}
437
438#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
440#[serde(default)] pub struct ComSettings {
443 pub protocol: ComProtocol,
445
446 pub socket_path: PathBuf,
448
449 pub port: u16,
452 pub address: IpAddr,
454}
455
456impl Default for ComSettings {
457 fn default() -> Self {
458 let socket_path = std::env::temp_dir().join("termusic.socket");
460
461 Self {
462 #[cfg(unix)]
463 protocol: ComProtocol::UDS,
464 #[cfg(not(unix))]
465 protocol: ComProtocol::HTTP,
466
467 socket_path,
468
469 port: 50101,
470 address: "::1".parse().unwrap(),
471 }
472 }
473}
474
475impl From<&ComSettings> for SocketAddr {
476 fn from(value: &ComSettings) -> Self {
477 Self::new(value.address, value.port)
478 }
479}
480
481mod v1_interop {
482 use std::num::TryFromIntError;
483
484 use super::{
485 backends::BackendSettings, Backend, ComSettings, LoopMode, NonZeroU32, NonZeroU8,
486 PlayerSettings, PodcastSettings, PositionYesNo, PositionYesNoLower, RememberLastPosition,
487 ScanDepth, SeekStep, ServerSettings,
488 };
489 use crate::config::v1;
490
491 impl From<v1::Loop> for LoopMode {
492 fn from(value: v1::Loop) -> Self {
493 match value {
494 v1::Loop::Single => Self::Single,
495 v1::Loop::Playlist => Self::Playlist,
496 v1::Loop::Random => Self::Random,
497 }
498 }
499 }
500
501 impl From<v1::SeekStep> for SeekStep {
502 fn from(value: v1::SeekStep) -> Self {
503 match value {
504 v1::SeekStep::Short => Self::Both(NonZeroU32::new(5).unwrap()),
505 v1::SeekStep::Long => Self::Both(NonZeroU32::new(30).unwrap()),
506 v1::SeekStep::Auto => Self::Depends {
507 short_tracks: NonZeroU32::new(5).unwrap(),
508 long_tracks: NonZeroU32::new(30).unwrap(),
509 },
510 }
511 }
512 }
513
514 impl From<v1::LastPosition> for RememberLastPosition {
515 fn from(value: v1::LastPosition) -> Self {
516 match value {
517 v1::LastPosition::Yes => Self::All(PositionYesNo::Simple(PositionYesNoLower::Yes)),
518 v1::LastPosition::No => Self::All(PositionYesNo::Simple(PositionYesNoLower::No)),
519 v1::LastPosition::Auto => Self::Depends {
521 music: PositionYesNo::Simple(PositionYesNoLower::No),
522 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
523 },
524 }
525 }
526 }
527
528 #[derive(Debug, Clone, PartialEq, thiserror::Error)]
530 pub enum ServerSettingsConvertError {
531 #[error("Zero value where expecting a non-zero value. old-config key: '{old_key}', new-config key: '{new_key}', error: {source}")]
533 ZeroValue {
534 old_key: &'static str,
535 new_key: &'static str,
536 #[source]
537 source: TryFromIntError,
538 },
539 }
540
541 impl TryFrom<v1::Settings> for ServerSettings {
542 type Error = ServerSettingsConvertError;
543
544 #[allow(clippy::cast_possible_truncation)] fn try_from(value: v1::Settings) -> Result<Self, Self::Error> {
546 let com_settings = ComSettings {
547 protocol: super::ComProtocol::HTTP,
549 port: value.player_port,
550 address: value.player_interface,
551 ..Default::default()
552 };
553
554 let podcast_settings = PodcastSettings {
555 concurrent_downloads_max: NonZeroU8::try_from(
556 value
557 .podcast_simultanious_download
558 .clamp(0, u8::MAX as usize) as u8,
559 )
560 .map_err(|err| ServerSettingsConvertError::ZeroValue {
561 old_key: "podcast_simultanious_download",
562 new_key: "podcast.concurrent_downloads_max",
563 source: err,
564 })?,
565 max_download_retries: value.podcast_max_retries.clamp(0, u8::MAX as usize) as u8,
566 download_dir: value.podcast_dir,
567 };
568
569 let player_settings = PlayerSettings {
570 music_dirs: value.music_dir,
571 library_scan_depth: ScanDepth::Limited(10),
574 remember_position: value.player_remember_last_played_position.into(),
575 loop_mode: value.player_loop_mode.into(),
576 volume: value.player_volume,
577 speed: value.player_speed,
578 gapless: value.player_gapless,
579 seek_step: value.player_seek_step.into(),
580
581 use_mediacontrols: value.player_use_mpris,
582 set_discord_status: value.player_use_discord,
583
584 random_track_quantity: NonZeroU32::try_from(
585 value.playlist_select_random_track_quantity,
586 )
587 .map_err(|err| ServerSettingsConvertError::ZeroValue {
588 old_key: "playlist_select_random_track_quantity",
589 new_key: "player.random_track_quantity",
590 source: err,
591 })?,
592 random_album_min_quantity: NonZeroU32::try_from(
593 value.playlist_select_random_album_quantity,
594 )
595 .map_err(|err| ServerSettingsConvertError::ZeroValue {
596 old_key: "playlist_select_random_album_quantity",
597 new_key: "player.random_album_min_quantity",
598 source: err,
599 })?,
600
601 backend: Backend::default(),
602 };
603
604 Ok(Self {
605 com: com_settings,
606 player: player_settings,
607 podcast: podcast_settings,
608 backends: BackendSettings::default(),
609 })
610 }
611 }
612
613 #[cfg(test)]
614 mod tests {
615 use pretty_assertions::assert_eq;
616 use std::path::PathBuf;
617
618 use crate::config::v2::server::ComProtocol;
619
620 use super::*;
621
622 #[test]
623 fn should_convert_default_without_error() {
624 let converted: ServerSettings = v1::Settings::default().try_into().unwrap();
625 assert!(converted.podcast.download_dir.components().count() > 0);
626 let podcast_settings = {
627 let mut set = converted.podcast;
628 set.download_dir = PathBuf::new();
630 set
631 };
632
633 assert_eq!(
634 podcast_settings,
635 PodcastSettings {
636 concurrent_downloads_max: NonZeroU8::new(3).unwrap(),
637 max_download_retries: 3,
638 download_dir: PathBuf::new()
639 }
640 );
641
642 assert_eq!(
643 converted.com,
644 ComSettings {
645 protocol: ComProtocol::HTTP,
646 port: 50101,
647 address: "::1".parse().unwrap(),
648 ..Default::default()
649 }
650 );
651
652 assert!(!converted.player.music_dirs.is_empty());
653
654 let player_settings = {
655 let mut set = converted.player;
656 set.music_dirs.clear();
658 set
659 };
660
661 assert_eq!(
662 player_settings,
663 PlayerSettings {
664 music_dirs: Vec::new(),
665 library_scan_depth: ScanDepth::Limited(10),
666 remember_position: RememberLastPosition::Depends {
667 music: PositionYesNo::Simple(PositionYesNoLower::No),
668 podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
669 },
670 loop_mode: LoopMode::Random,
671 volume: 70,
672 speed: 10,
673 gapless: true,
674 seek_step: SeekStep::Depends {
675 short_tracks: NonZeroU32::new(5).unwrap(),
676 long_tracks: NonZeroU32::new(30).unwrap(),
677 },
678 use_mediacontrols: true,
679 set_discord_status: true,
680 random_track_quantity: NonZeroU32::new(20).unwrap(),
681 random_album_min_quantity: NonZeroU32::new(5).unwrap(),
682 backend: Backend::default(),
683 }
684 );
685 }
686 }
687}