termusiclib/config/v2/server/
mod.rs

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;
14/// Extra things necessary for a config file, like wrappers for versioning
15pub mod config_extra;
16
17pub type MusicDirsOwned = Vec<PathBuf>;
18
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
20#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
21#[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)] // allow missing fields and fill them with the `..Self::default()` in this struct
31pub struct PodcastSettings {
32    /// Max Concurrent Downloads for Podcasts
33    // realistically, we dont have any more than 255 running
34    pub concurrent_downloads_max: NonZeroU8,
35    /// Max retries for Podcast downloads
36    // realistically, we dont have any more than 255 retries
37    pub max_download_retries: u8,
38    /// Directory for downloaded Podcasts
39    pub download_dir: PathBuf,
40}
41
42/// Get the default podcast dir, which uses OS-specific paths, or home/Music/podcast
43fn 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// note that regardless of options, loops should never happen and also should never go outside of the root music_dir
64#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
65#[serde(untagged)]
66pub enum ScanDepth {
67    /// Only go X deep
68    // realistically, we dont have any more than u32::MAX depth
69    Limited(u32),
70    /// Allow going fully down without limit
71    Unlimited,
72}
73
74/// What determines a long track length in seconds, 10 minutes
75const LONG_TRACK_TIME: u64 = 600; // 60 * 10
76
77/// Seek amount maybe depending on track length
78#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
79#[serde(untagged)]
80pub enum SeekStep {
81    /// Only have one seek-step value
82    Both(NonZeroU32),
83    /// Have different values depending on track type
84    Depends {
85        /// tracks < 10 minutes (like Music)
86        short_tracks: NonZeroU32,
87        /// tracks =>10 minutes (like Podcasts)
88        long_tracks: NonZeroU32,
89    },
90}
91
92impl SeekStep {
93    #[allow(clippy::missing_panics_doc)] // const unwrap
94    #[must_use]
95    pub fn default_both() -> Self {
96        Self::Both(NonZeroU32::new(5).unwrap())
97    }
98
99    #[allow(clippy::missing_panics_doc)] // const unwrap
100    #[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    /// Get Seek Step, depending on track-length
109    ///
110    /// directly returns a i64, though the value is never negative returned from here
111    #[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    /// Remember position, automatically decide after how much time
139    Yes,
140    /// Dont remember position
141    No,
142}
143
144/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Music`]
145const DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC: u64 = 3;
146
147/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Podcast`]
148const DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST: u64 = 10;
149
150// this exists because "serde(rename_all)" and "serde(untagged)" dont work well together
151#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
152#[serde(untagged)]
153pub enum PositionYesNo {
154    /// Simple wrapper to workaround the `"serde(rename_all)" and "serde(untagged)"` problem
155    Simple(PositionYesNoLower),
156    /// Remember Position after custom time (in seconds)
157    YesTime(u64),
158}
159
160impl PositionYesNo {
161    /// Get the time before saving the track position, if enabled
162    #[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    /// Get if the current value means "it is enabled"
178    #[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    /// Apply a single value to all media types
191    All(PositionYesNo),
192    /// Set specific values for each media type
193    Depends {
194        music: PositionYesNo,
195        podcast: PositionYesNo,
196    },
197}
198
199impl RememberLastPosition {
200    /// Get the time before saving the track position, if enabled
201    #[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    /// Get if remembering for the given [`MediaTypesSimple`] is enabled or not
214    ///
215    /// use case is in the restore of the last position
216    #[allow(clippy::needless_pass_by_value)] // "MediaTypesSimple" is a 1-byte copy
217    #[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                // liveradio cannot store a position
225                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)] // allow missing fields and fill them with the `..Self::default()` in this struct
242pub struct PlayerSettings {
243    /// Music Directories
244    pub music_dirs: MusicDirsOwned,
245    /// Max depth for music library scanning
246    ///
247    /// This for example affects how deep the auto-tag extraction will go
248    pub library_scan_depth: ScanDepth,
249    /// Set if the position should be remembered for tracks
250    pub remember_position: RememberLastPosition,
251
252    /// Playlist loop mode
253    pub loop_mode: LoopMode,
254    /// Volume, how loud something is
255    pub volume: u16,
256    /// Speed, both positive (forward) or negative (backwards)
257    ///
258    /// speed / 10 = actual speed (float but not floats)
259    // the number should never be 0, because that would effectively be paused forever
260    pub speed: i32,
261    /// Enable gapless decoding & prefetching the next track
262    pub gapless: bool,
263    /// How much to seek on a seek event
264    pub seek_step: SeekStep,
265
266    /// Controls if support via Media-Controls (like mpris on linux) is enabled
267    pub use_mediacontrols: bool,
268    /// Controls if discord status setting is enabled
269    pub set_discord_status: bool,
270
271    /// Amount of tracks to add on "random track add"
272    pub random_track_quantity: NonZeroU32,
273    /// Minimal amount of tracks a album needs to have before being chosen for "random album add"
274    pub random_album_min_quantity: NonZeroU32,
275
276    /// The backend to use
277    pub backend: Backend,
278}
279
280/// Get the default Music dir, which uses OS-specific paths, or home/Music
281fn 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            // rather use a lower value than a high so that ears dont get blown off
296            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/// Playlist loop modes
341#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
342#[serde(rename_all = "lowercase")]
343#[repr(u8)]
344pub enum LoopMode {
345    /// Loop one track
346    Single = 0,
347    /// Loop the entire Playlist (after last index comes the first)
348    #[default]
349    Playlist = 1,
350    /// Select a random track on each next track
351    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    /// Convert the current enum variant into its number representation
373    #[must_use]
374    pub fn discriminant(&self) -> u8 {
375        (*self) as u8
376    }
377
378    /// Try to convert the input number representation to a variant
379    #[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/// Error for when [`ComProtocol`] parsing fails
391#[derive(Debug, Clone, PartialEq, thiserror::Error)]
392pub enum ComProtocolParseError {
393    #[error("Expected \"uds\" or \"http\", found \"{0}\"")]
394    UnknownValue(String),
395}
396
397/// The Protocol to use for the server-client communication
398#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
399#[serde(try_from = "String")]
400#[serde(into = "String")]
401pub enum ComProtocol {
402    HTTP,
403    /// Unix socket
404    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/// Settings for the gRPC server (and potentially future ways to communicate)
439#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
440// for now, require that both port and ip are specified at once
441#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
442pub struct ComSettings {
443    // General Settings
444    pub protocol: ComProtocol,
445
446    // UDS settings
447    pub socket_path: PathBuf,
448
449    // Below are HTTP settings
450    /// gRPC server Port
451    pub port: u16,
452    /// gRPC server interface / address
453    pub address: IpAddr,
454}
455
456impl Default for ComSettings {
457    fn default() -> Self {
458        // TODO: maybe default to include user id like "termusic-1000.socket"?
459        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                // "Yes" is already automatic based on MediaType, using this here so that it will get serialized differently than the normal "All-Yes"
520                v1::LastPosition::Auto => Self::Depends {
521                    music: PositionYesNo::Simple(PositionYesNoLower::No),
522                    podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
523                },
524            }
525        }
526    }
527
528    /// Error for when [`ServerSettings`] convertion fails
529    #[derive(Debug, Clone, PartialEq, thiserror::Error)]
530    pub enum ServerSettingsConvertError {
531        /// Recieved a zero value expecting a non-zero value
532        #[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)] // checked casts
545        fn try_from(value: v1::Settings) -> Result<Self, Self::Error> {
546            let com_settings = ComSettings {
547                // when coming from v1, continue using http until manually changed
548                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                // not converting old scan_depth as that is not stored in the config, but set via CLI, using default instead
572                // library_scan_depth: ScanDepth::Limited(value.max_depth_cli),
573                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                // ignore this while comparing
629                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                // ignore this while comparing
657                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}