1use serde::{Deserialize, Deserializer, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8mod source;
9mod views;
10pub use source::{Browser, JellyfinServer, MusicServer, MusicService, SavedServer, Source};
11pub use views::{IntegrationConfig, LibraryConfig, PlaybackConfig, ServerAuth, UiConfig};
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
14pub enum FetchStrategy {
15 #[default]
16 MusicBrainzFirst,
17 LastFmFirst,
18 MusicBrainzOnly,
19 LastFmOnly,
20}
21
22pub const DEFAULT_REGISTRY_URL: &str =
24 "https://raw.githubusercontent.com/Kopuz-org/kopuz/refs/heads/master/radio-registry/index.json";
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct RegistryEntry {
28 pub url: String,
29 #[serde(default = "default_true")]
30 pub enabled: bool,
31 #[serde(default)]
32 pub is_default: bool,
33}
34
35pub fn default_radio_registries() -> Vec<RegistryEntry> {
36 vec![RegistryEntry {
37 url: DEFAULT_REGISTRY_URL.to_string(),
38 enabled: true,
39 is_default: true,
40 }]
41}
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct YtdlpOptions {
44 #[serde(default = "default_true")]
45 pub embed_metadata: bool,
46 #[serde(default = "default_true")]
47 pub embed_thumbnail: bool,
48 #[serde(default)]
49 pub postprocess_thumbnail_square: bool,
50 #[serde(default)]
51 pub embed_chapters: bool,
52 #[serde(default)]
53 pub embed_subs: bool,
54 #[serde(default)]
55 pub embed_info_json: bool,
56 #[serde(default)]
57 pub write_thumbnail: bool,
58 #[serde(default)]
59 pub write_description: bool,
60 #[serde(default)]
61 pub write_info_json: bool,
62 #[serde(default)]
63 pub write_subs: bool,
64 #[serde(default)]
65 pub write_auto_subs: bool,
66 #[serde(default)]
67 pub write_comments: bool,
68 #[serde(default)]
69 pub sponsorblock: bool,
70 #[serde(default)]
71 pub sponsorblock_mark: bool,
72 #[serde(default)]
73 pub split_chapters: bool,
74 #[serde(default)]
75 pub convert_thumbnail: String,
76 #[serde(default)]
77 pub no_playlist: bool,
78 #[serde(default)]
79 pub xattrs: bool,
80 #[serde(default)]
81 pub no_mtime: bool,
82 #[serde(default)]
83 pub rate_limit: String,
84 #[serde(default)]
85 pub cookies_from_browser: String,
86 #[serde(default)]
87 pub js_runtimes: String,
88 #[serde(default = "default_audio_quality")]
89 pub audio_quality: u8,
90}
91
92impl Default for YtdlpOptions {
93 fn default() -> Self {
94 Self {
95 embed_metadata: true,
96 embed_thumbnail: true,
97 postprocess_thumbnail_square: false,
98 embed_chapters: false,
99 embed_subs: false,
100 embed_info_json: false,
101 write_thumbnail: false,
102 write_description: false,
103 write_info_json: false,
104 write_subs: false,
105 write_auto_subs: false,
106 write_comments: false,
107 sponsorblock: false,
108 sponsorblock_mark: false,
109 split_chapters: false,
110 convert_thumbnail: String::new(),
111 no_playlist: false,
112 xattrs: false,
113 no_mtime: false,
114 rate_limit: String::new(),
115 cookies_from_browser: String::new(),
116 js_runtimes: String::new(),
117 audio_quality: 0,
118 }
119 }
120}
121
122fn default_true() -> bool {
123 true
124}
125fn default_audio_quality() -> u8 {
126 0
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct YtdlpHistoryEntry {
131 pub url: String,
132 pub title: String,
133 pub format: String,
134 pub status: String,
135 #[serde(default)]
136 pub error: Option<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct CustomTheme {
141 pub name: String,
142 pub vars: HashMap<String, String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub enum SortOrder {
147 Title,
148 Artist,
149 Album,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153pub enum ArtistViewOrder {
154 Tracks,
155 Albums,
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
159pub enum ArtistPhotoSource {
160 #[default]
161 AlbumCover,
162 ArtistPhoto,
163}
164
165#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
166pub enum BackBehavior {
167 #[default]
168 RewindThenPrev,
169 AlwaysPrev,
170}
171
172#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
173pub enum ChannelMode {
174 #[default]
175 Stereo,
176 Mono,
177 LeftOnly,
178 RightOnly,
179 SwapLeftRight,
180}
181
182impl ChannelMode {
183 pub const ALL: &'static [Self] = &[
184 Self::Stereo,
185 Self::Mono,
186 Self::LeftOnly,
187 Self::RightOnly,
188 Self::SwapLeftRight,
189 ];
190
191 pub const fn value_str(self) -> &'static str {
192 match self {
193 Self::Stereo => "stereo",
194 Self::Mono => "mono",
195 Self::LeftOnly => "left-only",
196 Self::RightOnly => "right-only",
197 Self::SwapLeftRight => "swap-left-right",
198 }
199 }
200
201 pub fn from_value_str(value: &str) -> Self {
202 match value {
203 "mono" => Self::Mono,
204 "left-only" => Self::LeftOnly,
205 "right-only" => Self::RightOnly,
206 "swap-left-right" => Self::SwapLeftRight,
207 _ => Self::Stereo,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
213pub enum EqPreset {
214 #[default]
215 Flat,
216 BassBoost,
217 TrebleBoost,
218 VocalBoost,
219 Loudness,
220 Custom,
221}
222
223impl EqPreset {
224 pub const fn all() -> [Self; 6] {
225 [
226 Self::Flat,
227 Self::BassBoost,
228 Self::TrebleBoost,
229 Self::VocalBoost,
230 Self::Loudness,
231 Self::Custom,
232 ]
233 }
234
235 pub const fn as_storage(self) -> &'static str {
236 match self {
237 Self::Flat => "flat",
238 Self::BassBoost => "bass-boost",
239 Self::TrebleBoost => "treble-boost",
240 Self::VocalBoost => "vocal-boost",
241 Self::Loudness => "loudness",
242 Self::Custom => "custom",
243 }
244 }
245
246 pub const fn label(self) -> &'static str {
247 match self {
248 Self::Flat => "Flat",
249 Self::BassBoost => "Bass Boost",
250 Self::TrebleBoost => "Treble Boost",
251 Self::VocalBoost => "Vocal Boost",
252 Self::Loudness => "Loudness",
253 Self::Custom => "Custom",
254 }
255 }
256
257 pub fn from_storage(value: &str) -> Self {
258 match value {
259 "bass-boost" => Self::BassBoost,
260 "treble-boost" => Self::TrebleBoost,
261 "vocal-boost" => Self::VocalBoost,
262 "loudness" => Self::Loudness,
263 "custom" => Self::Custom,
264 _ => Self::Flat,
265 }
266 }
267
268 pub const fn gains(self) -> [f32; 5] {
269 match self {
270 Self::Flat | Self::Custom => [0.0, 0.0, 0.0, 0.0, 0.0],
271 Self::BassBoost => [6.0, 4.5, 2.0, -0.5, -1.5],
272 Self::TrebleBoost => [-1.5, -0.5, 0.5, 4.0, 6.0],
273 Self::VocalBoost => [-2.0, 0.5, 3.5, 2.5, -0.5],
274 Self::Loudness => [4.0, 2.0, 0.5, 2.5, 4.0],
275 }
276 }
277
278 pub const fn default_preamp_db(self) -> Option<f32> {
279 match self {
280 Self::Flat => Some(0.0),
281 Self::BassBoost => Some(-4.0),
282 Self::TrebleBoost => Some(-2.0),
283 Self::VocalBoost => Some(-1.5),
284 Self::Loudness => Some(-5.0),
285 Self::Custom => None,
286 }
287 }
288}
289
290fn default_eq_bands() -> [f32; 5] {
291 [0.0, 0.0, 0.0, 0.0, 0.0]
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
295pub struct EqualizerSettings {
296 #[serde(default)]
297 pub enabled: bool,
298 #[serde(default)]
299 pub preset: EqPreset,
300 #[serde(default = "default_eq_bands")]
301 pub bands: [f32; 5],
302 #[serde(default)]
303 pub preamp_db: f32,
304}
305
306impl EqualizerSettings {
307 pub fn resolved_bands(&self) -> [f32; 5] {
308 if self.preset == EqPreset::Custom {
309 self.bands
310 } else {
311 self.preset.gains()
312 }
313 }
314}
315
316impl Default for EqualizerSettings {
317 fn default() -> Self {
318 Self {
319 enabled: false,
320 preset: EqPreset::Flat,
321 bands: default_eq_bands(),
322 preamp_db: 0.0,
323 }
324 }
325}
326
327#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
328pub enum OfflineQuality {
329 Kbps128,
330 Kbps160,
331 Kbps192,
332 Kbps256,
333 #[default]
334 Kbps320,
335 Original,
336}
337
338impl OfflineQuality {
339 pub const ALL: &'static [Self] = &[
340 Self::Kbps128,
341 Self::Kbps160,
342 Self::Kbps192,
343 Self::Kbps256,
344 Self::Kbps320,
345 Self::Original,
346 ];
347
348 pub fn label(self) -> &'static str {
349 match self {
350 Self::Kbps128 => "128 kbps",
351 Self::Kbps160 => "160 kbps",
352 Self::Kbps192 => "192 kbps",
353 Self::Kbps256 => "256 kbps",
354 Self::Kbps320 => "320 kbps",
355 Self::Original => "Original",
356 }
357 }
358
359 pub fn value_str(self) -> &'static str {
360 match self {
361 Self::Kbps128 => "128",
362 Self::Kbps160 => "160",
363 Self::Kbps192 => "192",
364 Self::Kbps256 => "256",
365 Self::Kbps320 => "320",
366 Self::Original => "original",
367 }
368 }
369
370 pub fn from_value_str(s: &str) -> Self {
371 match s {
372 "128" => Self::Kbps128,
373 "160" => Self::Kbps160,
374 "192" => Self::Kbps192,
375 "256" => Self::Kbps256,
376 "320" => Self::Kbps320,
377 _ => Self::Original,
378 }
379 }
380
381 pub fn jellyfin_bitrate_bps(self) -> Option<u32> {
382 match self {
383 Self::Kbps128 => Some(128_000),
384 Self::Kbps160 => Some(160_000),
385 Self::Kbps192 => Some(192_000),
386 Self::Kbps256 => Some(256_000),
387 Self::Kbps320 => Some(320_000),
388 Self::Original => None,
389 }
390 }
391
392 pub fn subsonic_max_bitrate_kbps(self) -> u32 {
393 match self {
394 Self::Kbps128 => 128,
395 Self::Kbps160 => 160,
396 Self::Kbps192 => 192,
397 Self::Kbps256 => 256,
398 Self::Kbps320 => 320,
399 Self::Original => 0,
400 }
401 }
402
403 pub fn file_extension(self) -> &'static str {
404 match self {
405 Self::Original => "bin",
406 _ => "mp3",
407 }
408 }
409}
410
411#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
412pub enum TitlebarMode {
413 #[default]
414 Custom,
415 System,
416 Off,
417}
418
419#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
420pub enum PlayerBarPosition {
421 #[default]
422 Bottom,
423 Top,
424}
425
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
427pub enum UiStyle {
428 #[default]
429 Normal,
430 Modern,
431}
432
433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
434pub enum ListenNowStyle {
435 #[default]
436 List,
437 Cards,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
441pub struct HomeSection {
442 pub key: String,
443 #[serde(default = "default_true")]
444 pub enabled: bool,
445}
446
447pub const HOME_SECTION_KEYS: &[&str] = &[
448 "hero",
449 "continue_listening",
450 "listen_now",
451 "top_artists",
452 "new_releases",
453 "made_for_you",
454 "recently_added",
455 "playlists",
456];
457
458pub fn default_home_sections() -> Vec<HomeSection> {
459 HOME_SECTION_KEYS
460 .iter()
461 .map(|k| HomeSection {
462 key: (*k).to_string(),
463 enabled: true,
464 })
465 .collect()
466}
467
468fn default_hero_height() -> u32 {
469 300
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AppConfig {
474 #[serde(default)]
475 pub server: Option<MusicServer>,
476 #[serde(default)]
477 pub servers: Vec<SavedServer>,
478 #[serde(default)]
487 pub active_source: Source,
488 #[serde(default)]
489 pub source_explicitly_set: bool,
490 #[serde(default, deserialize_with = "deserialize_music_directories")]
491 pub music_directory: Vec<PathBuf>,
492 #[serde(default = "default_theme")]
493 pub theme: String,
494 #[serde(default = "default_device_id")]
495 pub device_id: String,
496 #[serde(default = "default_discord_presence")]
497 pub discord_presence: Option<bool>,
498 #[serde(default = "default_discord_presence_paused")]
499 pub discord_presence_paused: Option<bool>,
500 #[serde(default = "default_sort_order")]
501 pub sort_order: SortOrder,
502 #[serde(default = "default_artist_view_order")]
503 pub artist_view_order: ArtistViewOrder,
504 #[serde(default)]
505 pub listen_counts: HashMap<String, u64>,
506 #[serde(default)]
507 pub musicbrainz_token: String,
508 #[serde(default)]
509 pub lastfm_api_key: String,
510 #[serde(default)]
511 pub lastfm_api_secret: String,
512 #[serde(default)]
513 pub lastfm_session_key: String,
514 #[serde(default)]
515 pub librefm_api_key: String,
516 #[serde(default)]
517 pub librefm_api_secret: String,
518 #[serde(default)]
519 pub librefm_session_key: String,
520 #[serde(default = "default_language")]
521 pub language: String,
522 #[serde(default)]
523 pub reduce_animations: bool,
524 #[serde(default)]
528 pub tracing_enabled: bool,
529 #[serde(default = "default_auto_check_updates")]
530 pub auto_check_updates: bool,
531 #[serde(default)]
534 pub minimize_to_tray: bool,
535 #[serde(default = "default_show_source_toggle")]
536 pub show_source_toggle: bool,
537 #[serde(default = "default_sidebar_order")]
538 pub sidebar_order: Vec<String>,
539 #[serde(default = "default_volume")]
540 pub volume: f32,
541 #[serde(default = "default_volume_scroll_step")]
542 pub volume_scroll_step: f32,
543 #[serde(default = "default_crossfade_seconds")]
544 pub crossfade_seconds: u8,
545 #[serde(default)]
546 pub custom_themes: HashMap<String, CustomTheme>,
547 #[serde(default)]
548 pub back_behavior: BackBehavior,
549 #[serde(default)]
550 pub channel_mode: ChannelMode,
551 #[serde(default)]
552 pub equalizer: EqualizerSettings,
553 #[serde(default)]
554 pub ytdlp_output_dir: String,
555 #[serde(default)]
556 pub ytdlp_options: YtdlpOptions,
557 #[serde(default)]
558 pub ytdlp_history: Vec<YtdlpHistoryEntry>,
559 #[serde(default)]
560 pub titlebar_mode: TitlebarMode,
561 #[serde(default)]
562 pub offline_quality: OfflineQuality,
563 #[serde(default)]
564 pub offline_tracks: HashMap<String, String>,
565 #[serde(default)]
566 pub player_bar_position: PlayerBarPosition,
567 #[serde(default)]
568 pub ui_style: UiStyle,
569 #[serde(default = "default_hero_height")]
570 pub hero_height: u32,
571 #[serde(default = "default_home_sections")]
572 pub home_sections: Vec<HomeSection>,
573 #[serde(default)]
574 pub listen_now_style: ListenNowStyle,
575 #[serde(default)]
576 pub artist_photo_source: ArtistPhotoSource,
577 #[serde(default)]
578 pub auto_fetch_covers: bool,
579 #[serde(default)]
580 pub cover_fetch_strategy: FetchStrategy,
581 #[serde(default = "default_radio_registries")]
582 pub radio_registries: Vec<RegistryEntry>,
583 #[serde(default)]
584 pub prefer_local_lyrics: bool,
585 #[serde(default)]
586 pub enable_musixmatch_lyrics: bool,
587}
588
589fn default_theme() -> String {
590 "default".to_string()
591}
592
593fn default_device_id() -> String {
594 uuid::Uuid::new_v4().to_string()
595}
596
597fn default_discord_presence() -> Option<bool> {
598 Some(true)
599}
600
601fn default_discord_presence_paused() -> Option<bool> {
602 Some(true)
603}
604
605fn default_sort_order() -> SortOrder {
606 SortOrder::Title
607}
608
609fn default_artist_view_order() -> ArtistViewOrder {
610 ArtistViewOrder::Tracks
611}
612
613fn default_show_source_toggle() -> bool {
614 true
615}
616
617fn default_auto_check_updates() -> bool {
618 true
619}
620
621pub fn default_sidebar_order() -> Vec<String> {
622 vec![
623 "home".to_string(),
624 "search".to_string(),
625 "library".to_string(),
626 "albums".to_string(),
627 "artists".to_string(),
628 "playlists".to_string(),
629 "favorites".to_string(),
630 "radio".to_string(),
631 "activity".to_string(),
632 "ytdlp".to_string(),
633 ]
634}
635
636fn default_volume() -> f32 {
637 1.0
638}
639
640fn default_volume_scroll_step() -> f32 {
641 0.05
642}
643
644fn default_crossfade_seconds() -> u8 {
645 0
646}
647
648fn default_language() -> String {
649 "en".to_string()
650}
651
652fn deserialize_music_directories<'de, D>(deserializer: D) -> Result<Vec<PathBuf>, D::Error>
653where
654 D: Deserializer<'de>,
655{
656 #[derive(Deserialize)]
657 #[serde(untagged)]
658 enum OneOrMany {
659 One(PathBuf),
660 Many(Vec<PathBuf>),
661 }
662 match OneOrMany::deserialize(deserializer)? {
663 OneOrMany::One(p) => Ok(vec![p]),
664 OneOrMany::Many(v) => Ok(v),
665 }
666}
667
668impl Default for AppConfig {
669 fn default() -> Self {
670 let music_directory = directories::UserDirs::new()
671 .and_then(|u| u.audio_dir().map(|p| p.to_path_buf()))
672 .unwrap_or_else(|| PathBuf::from("./assets"));
673 Self {
674 server: None,
675 servers: Vec::new(),
676 active_source: Source::Local,
677 source_explicitly_set: false,
678 music_directory: vec![music_directory],
679 theme: default_theme(),
680 device_id: default_device_id(),
681 discord_presence: Some(true),
682 discord_presence_paused: Some(true),
683 sort_order: default_sort_order(),
684 artist_view_order: default_artist_view_order(),
685 listen_counts: HashMap::new(),
686 musicbrainz_token: String::new(),
687 lastfm_api_key: String::new(),
688 lastfm_api_secret: String::new(),
689 lastfm_session_key: String::new(),
690 librefm_api_key: String::new(),
691 librefm_api_secret: String::new(),
692 librefm_session_key: String::new(),
693 language: default_language(),
694 reduce_animations: false,
695 tracing_enabled: false,
696 auto_check_updates: default_auto_check_updates(),
697 minimize_to_tray: false,
698 show_source_toggle: default_show_source_toggle(),
699 sidebar_order: default_sidebar_order(),
700 volume: default_volume(),
701 volume_scroll_step: default_volume_scroll_step(),
702 crossfade_seconds: default_crossfade_seconds(),
703 custom_themes: HashMap::new(),
704 back_behavior: BackBehavior::RewindThenPrev,
705 channel_mode: ChannelMode::Stereo,
706 equalizer: EqualizerSettings::default(),
707 ytdlp_output_dir: String::new(),
708 ytdlp_options: YtdlpOptions::default(),
709 ytdlp_history: Vec::new(),
710 titlebar_mode: TitlebarMode::Custom,
711 offline_quality: OfflineQuality::default(),
712 offline_tracks: HashMap::new(),
713 player_bar_position: PlayerBarPosition::Bottom,
714 ui_style: UiStyle::Normal,
715 hero_height: default_hero_height(),
716 home_sections: default_home_sections(),
717 listen_now_style: ListenNowStyle::default(),
718 artist_photo_source: ArtistPhotoSource::AlbumCover,
719 auto_fetch_covers: false,
720 cover_fetch_strategy: FetchStrategy::default(),
721 radio_registries: default_radio_registries(),
722 prefer_local_lyrics: false,
723 enable_musixmatch_lyrics: false,
724 }
725 }
726}
727
728impl AppConfig {
729 pub fn migrate_home_sections(&mut self) {
730 let allowed: std::collections::HashSet<&&str> = HOME_SECTION_KEYS.iter().collect();
731 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
732 let existing = std::mem::take(&mut self.home_sections);
733 for s in existing {
734 if allowed.contains(&s.key.as_str()) && seen.insert(s.key.clone()) {
735 self.home_sections.push(s);
736 }
737 }
738 for key in HOME_SECTION_KEYS {
739 if !seen.contains(*key) {
740 self.home_sections.push(HomeSection {
741 key: (*key).to_string(),
742 enabled: true,
743 });
744 }
745 }
746 }
747
748 pub fn migrate_servers(&mut self) {
749 if let Some(server) = self.server.as_mut()
750 && server.id.is_none()
751 {
752 server.id = Some(uuid::Uuid::new_v4().to_string());
753 }
754 if let Some(server) = self.server.clone() {
755 let already = self.servers.iter().any(|s| s.matches(&server));
756 if !already {
757 let id = server
758 .id
759 .clone()
760 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
761 self.servers.push(SavedServer {
762 id,
763 name: server.name.clone(),
764 url: server.url.clone(),
765 service: server.service,
766 yt_browser: server.yt_browser,
767 yt_anonymous: server.yt_anonymous,
768 });
769 }
770 }
771 }
772
773 pub fn add_saved_server(&mut self, entry: SavedServer) {
774 if !self.servers.iter().any(|s| s.id == entry.id) {
775 self.servers.push(entry);
776 }
777 }
778
779 pub fn remove_saved_server(&mut self, id: &str) {
780 self.servers.retain(|s| s.id != id);
781 if let Some(active) = &self.server
782 && active.id.as_deref() == Some(id)
783 {
784 self.server = None;
785 }
786 }
787
788 pub fn find_saved_server(&self, id: &str) -> Option<&SavedServer> {
789 self.servers.iter().find(|s| s.id == id)
790 }
791
792 pub fn migrate_sidebar_order(&mut self) {
793 let all_keys = default_sidebar_order();
794 for key in &all_keys {
795 if !self.sidebar_order.iter().any(|k| k == key) {
796 self.sidebar_order.push(key.to_string());
797 }
798 }
799 self.sidebar_order.retain(|k| all_keys.contains(k));
800 }
801
802 pub fn migrate_registry_paths(&mut self) {
803 if !self.radio_registries.iter().any(|r| r.is_default) {
805 self.radio_registries.insert(
806 0,
807 RegistryEntry {
808 url: DEFAULT_REGISTRY_URL.to_string(),
809 enabled: true,
810 is_default: true,
811 },
812 );
813 }
814 }
815}
816
817impl AppConfig {
818 pub fn clear_active_server(&mut self) {
819 self.active_source = Source::Local;
820 self.server = None;
821 self.source_explicitly_set = true;
822 }
823
824 pub fn set_active_server_snapshot(&mut self, server: MusicServer) {
825 let source = server.id.clone().map_or(Source::Local, Source::Server);
826 self.active_source = source;
827 self.server = Some(server);
828 self.source_explicitly_set = true;
829 }
830
831 pub fn active_service(&self) -> Option<MusicService> {
832 self.active_source.server_id()?;
833 self.server.as_ref().map(|server| server.service)
834 }
835
836 pub fn uses_jellyfin_server(&self) -> bool {
837 self.active_service() == Some(MusicService::Jellyfin)
838 }
839
840 pub fn server_toggle_target(&self) -> Option<Source> {
844 self.active_source
845 .server_id()
846 .map(String::from)
847 .or_else(|| self.servers.first().map(|s| s.id.clone()))
848 .map(Source::Server)
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::{AppConfig, BackBehavior, Browser, MusicServer, ServerAuth};
855 use std::path::PathBuf;
856
857 #[test]
858 fn config_deserializes_legacy_single_music_directory() {
859 let json = r#"{
860 "music_directory": "/music"
861 }"#;
862
863 let config: AppConfig = serde_json::from_str(json).unwrap();
864
865 assert_eq!(config.music_directory, vec![PathBuf::from("/music")]);
866 }
867
868 #[test]
869 fn config_deserializes_multiple_music_directories() {
870 let json = r#"{
871 "music_directory": ["/music", "/archive"]
872 }"#;
873
874 let config: AppConfig = serde_json::from_str(json).unwrap();
875
876 assert_eq!(
877 config.music_directory,
878 vec![PathBuf::from("/music"), PathBuf::from("/archive")]
879 );
880 }
881
882 #[test]
883 fn playback_view_projects_playback_fields() {
884 let mut config = AppConfig {
885 volume: 0.4,
886 crossfade_seconds: 5,
887 back_behavior: BackBehavior::AlwaysPrev,
888 ..AppConfig::default()
889 };
890 config.equalizer.enabled = true;
891
892 let playback = config.playback();
893
894 assert_eq!(playback.volume, 0.4);
895 assert_eq!(playback.crossfade_seconds, 5);
896 assert_eq!(playback.back_behavior, BackBehavior::AlwaysPrev);
897 assert!(playback.equalizer.enabled);
898 }
899
900 #[test]
901 fn browser_signin_server_auth_is_typed() {
902 let mut server = MusicServer::new_with_service(
903 "yt".to_string(),
904 "https://music.youtube.com".to_string(),
905 super::MusicService::YtMusic,
906 );
907 server.yt_browser = Some(Browser::Brave);
908 server.yt_anonymous = true;
909
910 assert_eq!(
911 server.auth(),
912 ServerAuth::Browser {
913 browser: Some(Browser::Brave),
914 token: None,
915 user_id: None,
916 anonymous: true,
917 }
918 );
919 }
920}