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