Skip to main content

config/
lib.rs

1//! Configuration management for Kopuz: loads, saves, and migrates user settings
2//! (audio, theme, media servers, shortcuts) from a JSON config file.
3
4use 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
22// Maybe host on the website?
23pub 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    /// Id of the active server (`servers.id`), or `None` for local. The DB-backed
480    /// source of truth for "which server is active"; `server`/`servers` above are
481    /// hydrated from the `servers` table around it. (`server` stays for now so the
482    /// ~90 existing `config.server` readers keep working — they migrate to id-based
483    /// resolution with the auth-gate work.)
484    /// The active source: `Local` or `Server(id)`. Single source of truth for
485    /// "which source/server is active" — `server`/`servers` above are hydrated
486    /// from the `servers` table around it.
487    #[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    /// Opt-in chrome/Perfetto performance trace. Read at startup (the
528    /// subscriber is built once), so a change needs a restart. Adds runtime
529    /// overhead — surfaced with a warning in settings.
530    #[serde(default)]
531    pub tracing_enabled: bool,
532    #[serde(default = "default_auto_check_updates")]
533    pub auto_check_updates: bool,
534    /// Desktop-only: when enabled, closing the window hides it to the system
535    /// tray instead of quitting, so playback keeps running in the background.
536    #[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        // Ensure the default registry entry is always present
812        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    /// The server to activate when toggling into server mode: the current server
849    /// if already on one, else the first saved server. `None` ⇒ no servers, so
850    /// the toggle is a no-op.
851    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}