termusiclib/config/v2/server/
mod.rs

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;
15/// Extra things necessary for a config file, like wrappers for versioning
16pub 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 missing fields and fill them with the `..Self::default()` in this struct
23#[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)] // allow missing fields and fill them with the `..Self::default()` in this struct
34pub struct PodcastSettings {
35    /// Max Concurrent Downloads for Podcasts
36    // realistically, we dont have any more than 255 running
37    pub concurrent_downloads_max: NonZeroU8,
38    /// Max retries for Podcast downloads
39    // realistically, we dont have any more than 255 retries
40    pub max_download_retries: u8,
41    /// Directory for downloaded Podcasts
42    pub download_dir: PathBuf,
43}
44
45/// Get the default podcast dir, which uses OS-specific paths, or home/Music/podcast
46fn 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// note that regardless of options, loops should never happen and also should never go outside of the root music_dir
67#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
68#[serde(untagged)]
69pub enum ScanDepth {
70    /// Only go X deep
71    // realistically, we dont have any more than u32::MAX depth
72    Limited(u32),
73    /// Allow going fully down without limit
74    Unlimited,
75}
76
77/// What determines a long track length in seconds, 10 minutes
78const LONG_TRACK_TIME: u64 = 600; // 60 * 10
79
80/// Seek amount maybe depending on track length
81#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
82#[serde(untagged)]
83pub enum SeekStep {
84    /// Only have one seek-step value
85    Both(NonZeroU32),
86    /// Have different values depending on track type
87    Depends {
88        /// tracks < 10 minutes (like Music)
89        short_tracks: NonZeroU32,
90        /// tracks =>10 minutes (like Podcasts)
91        long_tracks: NonZeroU32,
92    },
93}
94
95impl SeekStep {
96    #[allow(clippy::missing_panics_doc)] // const unwrap
97    #[must_use]
98    pub fn default_both() -> Self {
99        Self::Both(NonZeroU32::new(5).unwrap())
100    }
101
102    #[allow(clippy::missing_panics_doc)] // const unwrap
103    #[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    /// Get Seek Step, depending on track-length
112    ///
113    /// directly returns a i64, though the value is never negative returned from here
114    #[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    /// Remember position, automatically decide after how much time
142    Yes,
143    /// Dont remember position
144    No,
145}
146
147/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Music`]
148const DEFAULT_YES_TIME_BEFORE_SAVE_MUSIC: u64 = 3;
149
150/// Default for [`PositionYesNoLower::Yes`] for [`MediaType::Podcast`]
151const DEFAULT_YES_TIME_BEFORE_SAVE_PODCAST: u64 = 10;
152
153// this exists because "serde(rename_all)" and "serde(untagged)" dont work well together
154#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
155#[serde(untagged)]
156pub enum PositionYesNo {
157    /// Simple wrapper to workaround the `"serde(rename_all)" and "serde(untagged)"` problem
158    Simple(PositionYesNoLower),
159    /// Remember Position after custom time (in seconds)
160    YesTime(u64),
161}
162
163impl PositionYesNo {
164    /// Get the time before saving the track position, if enabled
165    #[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    /// Get if the current value means "it is enabled"
181    #[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    /// Apply a single value to all media types
194    All(PositionYesNo),
195    /// Set specific values for each media type
196    Depends {
197        music: PositionYesNo,
198        podcast: PositionYesNo,
199    },
200}
201
202impl RememberLastPosition {
203    /// Get the time before saving the track position, if enabled
204    #[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    /// Get if remembering for the given [`MediaTypesSimple`] is enabled or not
217    ///
218    /// use case is in the restore of the last position
219    #[allow(clippy::needless_pass_by_value)] // "MediaTypesSimple" is a 1-byte copy
220    #[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                // liveradio cannot store a position
228                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)] // allow missing fields and fill them with the `..Self::default()` in this struct
245pub struct PlayerSettings {
246    /// Music Directories
247    pub music_dirs: MusicDirsOwned,
248    /// Legacy value, this still exists so that existing (older)configs parse without error.
249    /// But the actual value will be unused and discared.
250    /// The following is the old description:
251    ///
252    /// Max depth the TUI will scan for the music library tree
253    #[serde(skip_serializing)]
254    pub library_scan_depth: ScanDepth,
255    /// Set if the position should be remembered for tracks
256    pub remember_position: RememberLastPosition,
257
258    /// Playlist loop mode
259    pub loop_mode: LoopMode,
260    /// Volume, how loud something is
261    pub volume: u16,
262    /// Speed, both positive (forward) or negative (backwards)
263    ///
264    /// speed / 10 = actual speed (float but not floats)
265    // the number should never be 0, because that would effectively be paused forever
266    pub speed: i32,
267    /// Enable gapless decoding & prefetching the next track
268    pub gapless: bool,
269    /// How much to seek on a seek event
270    pub seek_step: SeekStep,
271
272    /// Controls if support via Media-Controls (like mpris on linux) is enabled
273    pub use_mediacontrols: bool,
274    /// Controls if discord status setting is enabled
275    pub set_discord_status: bool,
276
277    /// Amount of tracks to add on "random track add"
278    pub random_track_quantity: NonZeroU32,
279    /// Minimal amount of tracks a album needs to have before being chosen for "random album add"
280    pub random_album_min_quantity: NonZeroU32,
281
282    /// The backend to use
283    pub backend: Backend,
284}
285
286/// Get the default Music dir, which uses OS-specific paths, or home/Music
287fn 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            // rather use a lower value than a high so that ears dont get blown off
302            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/// Playlist loop modes
347#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
348#[serde(rename_all = "lowercase")]
349#[repr(u8)]
350pub enum LoopMode {
351    /// Loop one track
352    Single = 0,
353    /// Loop the entire Playlist (after last index comes the first)
354    #[default]
355    Playlist = 1,
356    /// Select a random track on each next track
357    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    /// Convert the current enum variant into its number representation
379    #[must_use]
380    pub fn discriminant(&self) -> u8 {
381        (*self) as u8
382    }
383
384    /// Try to convert the input number representation to a variant
385    #[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/// Error for when [`ComProtocol`] parsing fails
397#[derive(Debug, Clone, PartialEq, thiserror::Error)]
398pub enum ComProtocolParseError {
399    #[error("Expected \"uds\" or \"http\", found \"{0}\"")]
400    UnknownValue(String),
401}
402
403/// The Protocol to use for the server-client communication
404#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
405#[serde(try_from = "String")]
406#[serde(into = "String")]
407pub enum ComProtocol {
408    HTTP,
409    /// Unix socket
410    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/// Settings for the gRPC server (and potentially future ways to communicate)
445#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
446// for now, require that both port and ip are specified at once
447#[serde(default)] // allow missing fields and fill them with the `..Self::default()` in this struct
448pub struct ComSettings {
449    // General Settings
450    pub protocol: ComProtocol,
451
452    // UDS settings
453    pub socket_path: PathBuf,
454
455    // Below are HTTP settings
456    /// gRPC server Port
457    pub port: u16,
458    /// gRPC server interface / address
459    pub address: IpAddr,
460}
461
462/// Helper function to get the default UDS socker path.
463#[must_use]
464pub fn default_uds_socket_path() -> PathBuf {
465    // TODO: maybe default to include user id like "termusic-1000.socket"?
466    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                // "Yes" is already automatic based on MediaType, using this here so that it will get serialized differently than the normal "All-Yes"
530                v1::LastPosition::Auto => Self::Depends {
531                    music: PositionYesNo::Simple(PositionYesNoLower::No),
532                    podcast: PositionYesNo::Simple(PositionYesNoLower::Yes),
533                },
534            }
535        }
536    }
537
538    /// Error for when [`ServerSettings`] convertion fails
539    #[derive(Debug, Clone, PartialEq, thiserror::Error)]
540    pub enum ServerSettingsConvertError {
541        /// Recieved a zero value expecting a non-zero value
542        #[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)] // checked casts
557        fn try_from(value: v1::Settings) -> Result<Self, Self::Error> {
558            let com_settings = ComSettings {
559                // when coming from v1, continue using http until manually changed
560                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                // not converting old scan_depth as that is not stored in the config, but set via CLI, using default instead
584                // library_scan_depth: ScanDepth::Limited(value.max_depth_cli),
585                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                // ignore this while comparing
642                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                // ignore this while comparing
670                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}