termusiclib/config/v2/server/
mod.rs

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
11/// Extra things necessary for a config file, like wrappers for versioning
12pub mod config_extra;
13
14pub type MusicDirsOwned = Vec<PathBuf>;
15
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
17#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
18#[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)] // allow missing fields and fill them with the `..Self::default()` in this struct
27pub struct PodcastSettings {
28    /// Max Concurrent Downloads for Podcasts
29    // realistically, we dont have any more than 255 running
30    pub concurrent_downloads_max: NonZeroU8,
31    /// Max retries for Podcast downloads
32    // realistically, we dont have any more than 255 retries
33    pub max_download_retries: u8,
34    /// Directory for downloaded Podcasts
35    pub download_dir: PathBuf,
36}
37
38/// Get the default podcast dir, which uses OS-specific paths, or home/Music/podcast
39fn 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// note that regardless of options, loops should never happen and also should never go outside of the root music_dir
60#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
61#[serde(untagged)]
62pub enum ScanDepth {
63    /// Only go X deep
64    // realistically, we dont have any more than u32::MAX depth
65    Limited(u32),
66    /// Allow going fully down without limit
67    Unlimited,
68}
69
70/// What determines a long track length in seconds, 10 minutes
71const LONG_TRACK_TIME: u64 = 600; // 60 * 10
72
73/// Seek amount maybe depending on track length
74#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
75#[serde(untagged)]
76pub enum SeekStep {
77    /// Only have one seek-step value
78    Both(NonZeroU32),
79    /// Have different values depending on track type
80    Depends {
81        /// tracks < 10 minutes (like Music)
82        short_tracks: NonZeroU32,
83        /// tracks =>10 minutes (like Podcasts)
84        long_tracks: NonZeroU32,
85    },
86}
87
88impl SeekStep {
89    #[allow(clippy::missing_panics_doc)] // const unwrap
90    #[must_use]
91    pub fn default_both() -> Self {
92        Self::Both(NonZeroU32::new(5).unwrap())
93    }
94
95    #[allow(clippy::missing_panics_doc)] // const unwrap
96    #[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    /// Get Seek Step, depending on track-length
105    ///
106    /// directly returns a i64, though the value is never negative returned from here
107    #[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    /// Remember position, automatically decide after how much time
135    Yes,
136    /// Dont remember position
137    No,
138}
139
140/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Music`]
141const DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC: u64 = 3;
142
143/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Podcast`]
144const DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST: u64 = 10;
145
146// this exists because "serde(rename_all)" and "serde(untagged)" dont work well together
147#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
148#[serde(untagged)]
149pub enum PositionYesNo {
150    /// Simple wrapper to workaround the `"serde(rename_all)" and "serde(untagged)"` problem
151    Simple(PositionYesNoLower),
152    /// Remember Position after custom time (in seconds)
153    YesTime(u64),
154}
155
156impl PositionYesNo {
157    /// Get the time before saving the track position, if enabled
158    #[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    /// Get if the current value means "it is enabled"
174    #[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    /// Apply a single value to all media types
187    All(PositionYesNo),
188    /// Set specific values for each media type
189    Depends {
190        music: PositionYesNo,
191        podcast: PositionYesNo,
192    },
193}
194
195impl RememberLastPosition {
196    /// Get the time before saving the track position, if enabled
197    #[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    /// Get if remembering for the given [`MediaType`] is enabled or not
210    ///
211    /// use case is in the restore of the last position
212    #[allow(clippy::needless_pass_by_value)] // "MediaType" is a 1-byte copy
213    #[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                // liveradio cannot store a position
221                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)] // allow missing fields and fill them with the `..Self::default()` in this struct
238pub struct PlayerSettings {
239    /// Music Directories
240    pub music_dirs: MusicDirsOwned,
241    /// Max depth for music library scanning
242    ///
243    /// This for example affects how deep the auto-tag extraction will go
244    pub library_scan_depth: ScanDepth,
245    /// Set if the position should be remembered for tracks
246    pub remember_position: RememberLastPosition,
247
248    /// Playlist loop mode
249    pub loop_mode: LoopMode,
250    /// Volume, how loud something is
251    pub volume: u16,
252    /// Speed, both positive (forward) or negative (backwards)
253    ///
254    /// speed / 10 = actual speed (float but not floats)
255    // the number should never be 0, because that would effectively be paused forever
256    pub speed: i32,
257    /// Enable gapless decoding & prefetching the next track
258    pub gapless: bool,
259    /// How much to seek on a seek event
260    pub seek_step: SeekStep,
261
262    /// Controls if support via Media-Controls (like mpris on linux) is enabled
263    pub use_mediacontrols: bool,
264    /// Controls if discord status setting is enabled
265    pub set_discord_status: bool,
266
267    /// Amount of tracks to add on "random track add"
268    pub random_track_quantity: NonZeroU32,
269    /// Minimal amount of tracks a album needs to have before being chosen for "random album add"
270    pub random_album_min_quantity: NonZeroU32,
271}
272
273/// Get the default Music dir, which uses OS-specific paths, or home/Music
274fn 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            // rather use a lower value than a high so that ears dont get blown off
289            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/// Playlist loop modes
304#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
305#[serde(rename_all = "lowercase")]
306#[repr(u8)]
307pub enum LoopMode {
308    /// Loop one track
309    Single = 0,
310    /// Loop the entire Playlist (after last index comes the first)
311    #[default]
312    Playlist = 1,
313    /// Select a random track on each next track
314    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    /// Convert the current enum variant into its number representation
336    #[must_use]
337    pub fn discriminant(&self) -> u8 {
338        (*self) as u8
339    }
340
341    /// Try to convert the input number representation to a variant
342    #[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/// Settings for the gRPC server (and potentially future ways to communicate)
354#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
355// for now, require that both port and ip are specified at once
356// #[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
357pub struct ComSettings {
358    /// gRPC server Port
359    pub port: u16,
360    /// gRPC server interface / address
361    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                // "Yes" is already automatic based on MediaType, using this here so that it will get serialized differently than the normal "All-Yes"
418                v1::LastPosition::Auto => Self::Depends {
419                    music: PositionYesNo::Simple(PositionYesNoLower::No),
420                    podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
421                },
422            }
423        }
424    }
425
426    /// Error for when [`ServerSettings`] convertion fails
427    #[derive(Debug, Clone, PartialEq, thiserror::Error)]
428    pub enum ServerSettingsConvertError {
429        /// Recieved a zero value expecting a non-zero value
430        #[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)] // checked casts
443        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                // not converting old scan_depth as that is not stored in the config, but set via CLI, using default instead
467                // library_scan_depth: ScanDepth::Limited(value.max_depth_cli),
468                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                // ignore this while comparing
519                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                // ignore this while comparing
545                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}