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 #[serde(alias = "Modern")]
431 Vaxry,
432}
433
434#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
435pub enum ListenNowStyle {
436 #[default]
437 List,
438 Cards,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
442pub struct HomeSection {
443 pub key: String,
444 #[serde(default = "default_true")]
445 pub enabled: bool,
446}
447
448pub const HOME_SECTION_KEYS: &[&str] = &[
449 "hero",
450 "continue_listening",
451 "listen_now",
452 "top_artists",
453 "new_releases",
454 "made_for_you",
455 "recently_added",
456 "playlists",
457];
458
459pub fn default_home_sections() -> Vec<HomeSection> {
460 HOME_SECTION_KEYS
461 .iter()
462 .map(|k| HomeSection {
463 key: (*k).to_string(),
464 enabled: true,
465 })
466 .collect()
467}
468
469fn default_hero_height() -> u32 {
470 300
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct AppConfig {
475 #[serde(default)]
476 pub server: Option<MusicServer>,
477 #[serde(default)]
478 pub servers: Vec<SavedServer>,
479 #[serde(default)]
488 pub active_source: Source,
489 #[serde(default)]
490 pub source_explicitly_set: bool,
491 #[serde(default, deserialize_with = "deserialize_music_directories")]
492 pub music_directory: Vec<PathBuf>,
493 #[serde(default = "default_theme")]
494 pub theme: String,
495 #[serde(default = "default_device_id")]
496 pub device_id: String,
497 #[serde(default = "default_discord_presence")]
498 pub discord_presence: Option<bool>,
499 #[serde(default = "default_discord_presence_paused")]
500 pub discord_presence_paused: Option<bool>,
501 #[serde(default = "default_discord_presence_source")]
502 pub discord_presence_source: Option<bool>,
503 #[serde(default = "default_sort_order")]
504 pub sort_order: SortOrder,
505 #[serde(default = "default_artist_view_order")]
506 pub artist_view_order: ArtistViewOrder,
507 #[serde(default)]
508 pub listen_counts: HashMap<String, u64>,
509 #[serde(default)]
510 pub musicbrainz_token: String,
511 #[serde(default)]
512 pub lastfm_api_key: String,
513 #[serde(default)]
514 pub lastfm_api_secret: String,
515 #[serde(default)]
516 pub lastfm_session_key: String,
517 #[serde(default)]
518 pub librefm_api_key: String,
519 #[serde(default)]
520 pub librefm_api_secret: String,
521 #[serde(default)]
522 pub librefm_session_key: String,
523 #[serde(default = "default_language")]
524 pub language: String,
525 #[serde(default)]
526 pub reduce_animations: bool,
527 #[serde(default)]
531 pub tracing_enabled: bool,
532 #[serde(default = "default_auto_check_updates")]
533 pub auto_check_updates: bool,
534 #[serde(default)]
537 pub minimize_to_tray: bool,
538 #[serde(default = "default_show_source_toggle")]
539 pub show_source_toggle: bool,
540 #[serde(default = "default_sidebar_order")]
541 pub sidebar_order: Vec<String>,
542 #[serde(default = "default_volume")]
543 pub volume: f32,
544 #[serde(default = "default_volume_scroll_step")]
545 pub volume_scroll_step: f32,
546 #[serde(default = "default_crossfade_seconds")]
547 pub crossfade_seconds: u8,
548 #[serde(default)]
549 pub custom_themes: HashMap<String, CustomTheme>,
550 #[serde(default)]
551 pub back_behavior: BackBehavior,
552 #[serde(default)]
553 pub channel_mode: ChannelMode,
554 #[serde(default)]
555 pub equalizer: EqualizerSettings,
556 #[serde(default)]
557 pub ytdlp_output_dir: String,
558 #[serde(default)]
559 pub ytdlp_options: YtdlpOptions,
560 #[serde(default)]
561 pub ytdlp_history: Vec<YtdlpHistoryEntry>,
562 #[serde(default)]
563 pub titlebar_mode: TitlebarMode,
564 #[serde(default)]
565 pub offline_quality: OfflineQuality,
566 #[serde(default)]
567 pub offline_tracks: HashMap<String, String>,
568 #[serde(default)]
569 pub player_bar_position: PlayerBarPosition,
570 #[serde(default)]
571 pub ui_style: UiStyle,
572 #[serde(default = "default_hero_height")]
573 pub hero_height: u32,
574 #[serde(default = "default_home_sections")]
575 pub home_sections: Vec<HomeSection>,
576 #[serde(default)]
577 pub listen_now_style: ListenNowStyle,
578 #[serde(default)]
579 pub artist_photo_source: ArtistPhotoSource,
580 #[serde(default)]
581 pub auto_fetch_covers: bool,
582 #[serde(default)]
583 pub cover_fetch_strategy: FetchStrategy,
584 #[serde(default = "default_radio_registries")]
585 pub radio_registries: Vec<RegistryEntry>,
586 #[serde(default)]
587 pub prefer_local_lyrics: bool,
588 #[serde(default)]
589 pub enable_musixmatch_lyrics: bool,
590}
591
592fn default_theme() -> String {
593 "default".to_string()
594}
595
596fn default_device_id() -> String {
597 uuid::Uuid::new_v4().to_string()
598}
599
600fn default_discord_presence() -> Option<bool> {
601 Some(true)
602}
603
604fn default_discord_presence_paused() -> Option<bool> {
605 Some(true)
606}
607
608fn default_discord_presence_source() -> Option<bool> {
609 Some(true)
610}
611
612fn default_sort_order() -> SortOrder {
613 SortOrder::Title
614}
615
616fn default_artist_view_order() -> ArtistViewOrder {
617 ArtistViewOrder::Tracks
618}
619
620fn default_show_source_toggle() -> bool {
621 true
622}
623
624fn default_auto_check_updates() -> bool {
625 true
626}
627
628pub fn default_sidebar_order() -> Vec<String> {
629 vec![
630 "home".to_string(),
631 "search".to_string(),
632 "library".to_string(),
633 "albums".to_string(),
634 "artists".to_string(),
635 "playlists".to_string(),
636 "favorites".to_string(),
637 "radio".to_string(),
638 "activity".to_string(),
639 "ytdlp".to_string(),
640 ]
641}
642
643fn default_volume() -> f32 {
644 1.0
645}
646
647fn default_volume_scroll_step() -> f32 {
648 0.05
649}
650
651fn default_crossfade_seconds() -> u8 {
652 0
653}
654
655fn default_language() -> String {
656 "en".to_string()
657}
658
659fn deserialize_music_directories<'de, D>(deserializer: D) -> Result<Vec<PathBuf>, D::Error>
660where
661 D: Deserializer<'de>,
662{
663 #[derive(Deserialize)]
664 #[serde(untagged)]
665 enum OneOrMany {
666 One(PathBuf),
667 Many(Vec<PathBuf>),
668 }
669 match OneOrMany::deserialize(deserializer)? {
670 OneOrMany::One(p) => Ok(vec![p]),
671 OneOrMany::Many(v) => Ok(v),
672 }
673}
674
675impl Default for AppConfig {
676 fn default() -> Self {
677 let music_directory = directories::UserDirs::new()
678 .and_then(|u| u.audio_dir().map(|p| p.to_path_buf()))
679 .unwrap_or_else(|| PathBuf::from("./assets"));
680 Self {
681 server: None,
682 servers: Vec::new(),
683 active_source: Source::Local,
684 source_explicitly_set: false,
685 music_directory: vec![music_directory],
686 theme: default_theme(),
687 device_id: default_device_id(),
688 discord_presence: Some(true),
689 discord_presence_paused: Some(true),
690 discord_presence_source: Some(true),
691 sort_order: default_sort_order(),
692 artist_view_order: default_artist_view_order(),
693 listen_counts: HashMap::new(),
694 musicbrainz_token: String::new(),
695 lastfm_api_key: String::new(),
696 lastfm_api_secret: String::new(),
697 lastfm_session_key: String::new(),
698 librefm_api_key: String::new(),
699 librefm_api_secret: String::new(),
700 librefm_session_key: String::new(),
701 language: default_language(),
702 reduce_animations: false,
703 tracing_enabled: false,
704 auto_check_updates: default_auto_check_updates(),
705 minimize_to_tray: false,
706 show_source_toggle: default_show_source_toggle(),
707 sidebar_order: default_sidebar_order(),
708 volume: default_volume(),
709 volume_scroll_step: default_volume_scroll_step(),
710 crossfade_seconds: default_crossfade_seconds(),
711 custom_themes: HashMap::new(),
712 back_behavior: BackBehavior::RewindThenPrev,
713 channel_mode: ChannelMode::Stereo,
714 equalizer: EqualizerSettings::default(),
715 ytdlp_output_dir: String::new(),
716 ytdlp_options: YtdlpOptions::default(),
717 ytdlp_history: Vec::new(),
718 titlebar_mode: TitlebarMode::Custom,
719 offline_quality: OfflineQuality::default(),
720 offline_tracks: HashMap::new(),
721 player_bar_position: PlayerBarPosition::Bottom,
722 ui_style: UiStyle::Normal,
723 hero_height: default_hero_height(),
724 home_sections: default_home_sections(),
725 listen_now_style: ListenNowStyle::default(),
726 artist_photo_source: ArtistPhotoSource::AlbumCover,
727 auto_fetch_covers: false,
728 cover_fetch_strategy: FetchStrategy::default(),
729 radio_registries: default_radio_registries(),
730 prefer_local_lyrics: false,
731 enable_musixmatch_lyrics: false,
732 }
733 }
734}
735
736impl AppConfig {
737 pub fn migrate_home_sections(&mut self) {
738 let allowed: std::collections::HashSet<&&str> = HOME_SECTION_KEYS.iter().collect();
739 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
740 let existing = std::mem::take(&mut self.home_sections);
741 for s in existing {
742 if allowed.contains(&s.key.as_str()) && seen.insert(s.key.clone()) {
743 self.home_sections.push(s);
744 }
745 }
746 for key in HOME_SECTION_KEYS {
747 if !seen.contains(*key) {
748 self.home_sections.push(HomeSection {
749 key: (*key).to_string(),
750 enabled: true,
751 });
752 }
753 }
754 }
755
756 pub fn migrate_servers(&mut self) {
757 if let Some(server) = self.server.as_mut()
758 && server.id.is_none()
759 {
760 server.id = Some(uuid::Uuid::new_v4().to_string());
761 }
762 if let Some(server) = self.server.clone() {
763 let already = self.servers.iter().any(|s| s.matches(&server));
764 if !already {
765 let id = server
766 .id
767 .clone()
768 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
769 self.servers.push(SavedServer {
770 id,
771 name: server.name.clone(),
772 url: server.url.clone(),
773 service: server.service,
774 yt_browser: server.yt_browser,
775 yt_anonymous: server.yt_anonymous,
776 });
777 }
778 }
779 }
780
781 pub fn add_saved_server(&mut self, entry: SavedServer) {
782 if !self.servers.iter().any(|s| s.id == entry.id) {
783 self.servers.push(entry);
784 }
785 }
786
787 pub fn remove_saved_server(&mut self, id: &str) {
788 self.servers.retain(|s| s.id != id);
789 if let Some(active) = &self.server
790 && active.id.as_deref() == Some(id)
791 {
792 self.server = None;
793 }
794 }
795
796 pub fn find_saved_server(&self, id: &str) -> Option<&SavedServer> {
797 self.servers.iter().find(|s| s.id == id)
798 }
799
800 pub fn migrate_sidebar_order(&mut self) {
801 let all_keys = default_sidebar_order();
802 for key in &all_keys {
803 if !self.sidebar_order.iter().any(|k| k == key) {
804 self.sidebar_order.push(key.to_string());
805 }
806 }
807 self.sidebar_order.retain(|k| all_keys.contains(k));
808 }
809
810 pub fn migrate_registry_paths(&mut self) {
811 if !self.radio_registries.iter().any(|r| r.is_default) {
813 self.radio_registries.insert(
814 0,
815 RegistryEntry {
816 url: DEFAULT_REGISTRY_URL.to_string(),
817 enabled: true,
818 is_default: true,
819 },
820 );
821 }
822 }
823}
824
825impl AppConfig {
826 pub fn clear_active_server(&mut self) {
827 self.active_source = Source::Local;
828 self.server = None;
829 self.source_explicitly_set = true;
830 }
831
832 pub fn set_active_server_snapshot(&mut self, server: MusicServer) {
833 let source = server.id.clone().map_or(Source::Local, Source::Server);
834 self.active_source = source;
835 self.server = Some(server);
836 self.source_explicitly_set = true;
837 }
838
839 pub fn active_service(&self) -> Option<MusicService> {
840 self.active_source.server_id()?;
841 self.server.as_ref().map(|server| server.service)
842 }
843
844 pub fn uses_jellyfin_server(&self) -> bool {
845 self.active_service() == Some(MusicService::Jellyfin)
846 }
847
848 pub fn server_toggle_target(&self) -> Option<Source> {
852 self.active_source
853 .server_id()
854 .map(String::from)
855 .or_else(|| self.servers.first().map(|s| s.id.clone()))
856 .map(Source::Server)
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::{AppConfig, BackBehavior, Browser, MusicServer, ServerAuth};
863 use std::path::PathBuf;
864
865 #[test]
866 fn config_deserializes_legacy_single_music_directory() {
867 let json = r#"{
868 "music_directory": "/music"
869 }"#;
870
871 let config: AppConfig = serde_json::from_str(json).unwrap();
872
873 assert_eq!(config.music_directory, vec![PathBuf::from("/music")]);
874 }
875
876 #[test]
877 fn config_deserializes_multiple_music_directories() {
878 let json = r#"{
879 "music_directory": ["/music", "/archive"]
880 }"#;
881
882 let config: AppConfig = serde_json::from_str(json).unwrap();
883
884 assert_eq!(
885 config.music_directory,
886 vec![PathBuf::from("/music"), PathBuf::from("/archive")]
887 );
888 }
889
890 #[test]
891 fn playback_view_projects_playback_fields() {
892 let mut config = AppConfig {
893 volume: 0.4,
894 crossfade_seconds: 5,
895 back_behavior: BackBehavior::AlwaysPrev,
896 ..AppConfig::default()
897 };
898 config.equalizer.enabled = true;
899
900 let playback = config.playback();
901
902 assert_eq!(playback.volume, 0.4);
903 assert_eq!(playback.crossfade_seconds, 5);
904 assert_eq!(playback.back_behavior, BackBehavior::AlwaysPrev);
905 assert!(playback.equalizer.enabled);
906 }
907
908 #[test]
909 fn browser_signin_server_auth_is_typed() {
910 let mut server = MusicServer::new_with_service(
911 "yt".to_string(),
912 "https://music.youtube.com".to_string(),
913 super::MusicService::YtMusic,
914 );
915 server.yt_browser = Some(Browser::Brave);
916 server.yt_anonymous = true;
917
918 assert_eq!(
919 server.auth(),
920 ServerAuth::Browser {
921 browser: Some(Browser::Brave),
922 token: None,
923 user_id: None,
924 anonymous: true,
925 }
926 );
927 }
928}