1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock, Once};
5use std::time::SystemTime;
6
7use figment::Figment;
8use figment::providers::{Env, Format, Serialized, Toml};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ConfigError {
14 #[error("io error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("parse error: {0}")]
17 Parse(#[from] toml::de::Error),
18 #[error("serialize error: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("config error: {0}")]
21 Figment(#[from] Box<figment::Error>),
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub library: LibraryConfig,
28 pub playback: PlaybackConfig,
29 pub remote: RemoteConfig,
30 pub organize: OrganizeConfig,
31 #[serde(alias = "visualiser")]
32 pub visualizer: VisualizerConfig,
33 pub graphql: GraphqlConfig,
34 pub subsonic: SubsonicConfig,
35 pub auth: AuthConfig,
36 pub sharing: SharingConfig,
37 pub mcp: McpConfig,
38 pub push: PushConfig,
39 pub lastfm: LastfmConfig,
40 pub devices: DevicesConfig,
41 pub dsp: DspConfig,
42 pub appearance: AppearanceConfig,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct AppearanceConfig {
49 pub theme: String,
52 pub theme_icons: bool,
55 pub record_colours: bool,
59 pub wash_window: bool,
64 pub rainbow: bool,
67 pub text_size: u16,
70 pub wash_tint: u16,
76}
77
78impl Default for AppearanceConfig {
79 fn default() -> Self {
80 Self {
81 theme: "koan".into(),
82 theme_icons: true,
83 record_colours: true,
84 wash_window: true,
85 rainbow: false,
86 text_size: 100,
87 wash_tint: 100,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(default)]
95pub struct SharingConfig {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub public_url: Option<String>,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(default)]
111pub struct LastfmConfig {
112 #[serde(skip_serializing_if = "String::is_empty")]
113 pub api_key: String,
114 #[serde(skip_serializing_if = "String::is_empty")]
115 pub secret: String,
116}
117
118impl LastfmConfig {
119 pub fn is_set(&self) -> bool {
121 !self.api_key.trim().is_empty() && !self.secret.trim().is_empty()
122 }
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(default)]
128pub struct McpConfig {
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub redirect_hosts: Vec<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct PushConfig {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub key_path: Option<PathBuf>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub key: Option<String>,
150 #[serde(skip_serializing_if = "String::is_empty")]
152 pub key_id: String,
153 #[serde(skip_serializing_if = "String::is_empty")]
155 pub team_id: String,
156 pub topic: String,
158}
159
160impl Default for PushConfig {
161 fn default() -> Self {
162 Self {
163 key_path: None,
164 key: None,
165 key_id: String::new(),
166 team_id: String::new(),
167 topic: "cc.blit.koan".into(),
168 }
169 }
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(default)]
177pub struct LibraryConfig {
178 pub folders: Vec<PathBuf>,
179}
180
181pub fn former_default_folder() -> PathBuf {
185 dirs::audio_dir().unwrap_or_else(|| {
186 dirs::home_dir()
187 .map(|h| h.join("Music"))
188 .unwrap_or_else(|| PathBuf::from("/Music"))
189 })
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct PlaybackConfig {
195 pub replaygain: ReplayGainMode,
196 pub target_fps: u8,
199 pub show_fps: bool,
201 pub pre_amp_db: f64,
204 pub fade_on_pause: bool,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub output_device: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub renderer: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub renderer_name: Option<String>,
217 pub renderers: bool,
220 pub muted: bool,
223 pub art_size: u16,
226 pub art_graphics: bool,
230 pub tui_theme: String,
233 pub queue_grouped: bool,
235 pub rate_switch_lead_in_ms: u32,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum ReplayGainMode {
244 Off,
245 Track,
246 Album,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(default)]
251pub struct RemoteConfig {
252 pub enabled: bool,
253 pub url: String,
254 pub username: String,
255 #[serde(default, skip_serializing_if = "String::is_empty")]
257 pub password: String,
258 #[serde(default, skip_serializing_if = "String::is_empty")]
261 pub api_key: String,
262 #[serde(default, skip_serializing_if = "String::is_empty")]
268 pub device_key: String,
269 pub cache_dir: Option<PathBuf>,
271 pub download_workers: usize,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub cache_limit: Option<String>,
278 pub auto_sync: bool,
284 pub auto_sync_interval_mins: u64,
286 pub play_queue: bool,
292}
293
294impl Default for PlaybackConfig {
295 fn default() -> Self {
296 Self {
297 replaygain: ReplayGainMode::Off,
298 target_fps: 60,
299 show_fps: false,
300 pre_amp_db: 0.0,
301 fade_on_pause: true,
302 output_device: None,
303 renderer: None,
304 renderer_name: None,
305 renderers: true,
306 muted: false,
307 art_size: 24,
308 art_graphics: true,
309 tui_theme: "koan".into(),
310 queue_grouped: true,
311 rate_switch_lead_in_ms: 1000,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(default)]
318pub struct VisualizerConfig {
319 pub enabled: bool,
320 pub fps: u8,
321 pub mode: String,
324 pub scale: String,
326 pub amplitude_scale: String,
328 pub bar_decay_ms: u32,
330 pub peak_decay_ms: u32,
332 pub palette: String,
337 pub reactivity: f32,
341 pub bass_shake: bool,
344 pub matrix_overlay: bool,
347 pub reactive_bg: bool,
349}
350
351impl Default for VisualizerConfig {
352 fn default() -> Self {
353 Self {
354 enabled: true,
355 fps: 60,
356 mode: "bars".into(),
357 scale: "bark".into(),
358 amplitude_scale: "aweight".into(),
359 bar_decay_ms: 50,
360 peak_decay_ms: 180,
361 palette: "theme".into(),
362 reactivity: 1.0,
363 bass_shake: true,
364 matrix_overlay: false,
365 reactive_bg: false,
366 }
367 }
368}
369
370impl Default for RemoteConfig {
371 fn default() -> Self {
372 Self {
373 enabled: false,
374 url: String::new(),
375 username: String::new(),
376 password: String::new(),
377 api_key: String::new(),
378 device_key: String::new(),
379 cache_dir: None,
380 download_workers: 5,
381 cache_limit: None,
382 auto_sync: true,
383 auto_sync_interval_mins: 60,
384 play_queue: false,
385 }
386 }
387}
388
389pub fn parse_size_bytes(s: &str) -> Option<u64> {
392 let s = s.trim();
393 if s.is_empty() {
394 return None;
395 }
396
397 let mut num_end = 0;
399 for (i, c) in s.char_indices() {
400 if c.is_ascii_digit() || c == '.' {
401 num_end = i + c.len_utf8();
402 } else if !c.is_whitespace() {
403 break;
404 }
405 }
406
407 let num_str = s[..num_end].trim();
408 let suffix = s[num_end..].trim().to_ascii_uppercase();
409
410 let value: f64 = num_str.parse().ok()?;
411 let multiplier: u64 = match suffix.as_str() {
412 "" | "B" => 1,
413 "KB" | "K" => 1024,
414 "MB" | "M" => 1024 * 1024,
415 "GB" | "G" => 1024 * 1024 * 1024,
416 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
417 _ => return None,
418 };
419
420 Some((value * multiplier as f64) as u64)
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(default)]
425pub struct OrganizeConfig {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub default: Option<String>,
429 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
431 pub patterns: HashMap<String, String>,
432 #[serde(default = "default_true")]
436 pub move_ancillary: bool,
437}
438
439impl Default for OrganizeConfig {
440 fn default() -> Self {
441 Self {
442 default: None,
443 patterns: HashMap::new(),
444 move_ancillary: true,
445 }
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(default)]
452pub struct GraphqlConfig {
453 pub enabled: bool,
456 pub port: u16,
458 #[serde(default = "default_bind")]
461 pub bind: std::net::IpAddr,
462 pub playground: bool,
464 pub auth_enabled: bool,
467 pub access_token_ttl: String,
469 pub refresh_token_ttl: String,
471 pub cors_origins: Vec<String>,
474 pub allowed_hosts: Vec<String>,
478 pub cookie_secure: bool,
482 #[serde(default, skip_serializing_if = "String::is_empty")]
487 pub proxy_auth_header: String,
488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
491 pub proxy_auth_from: Vec<String>,
492 pub allow_organize: bool,
494 #[serde(default = "default_true")]
499 pub setup_wizard: bool,
500}
501
502fn default_true() -> bool {
503 true
504}
505
506fn default_bind() -> std::net::IpAddr {
507 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
508}
509
510impl Default for GraphqlConfig {
511 fn default() -> Self {
512 Self {
513 enabled: true,
514 port: 4000,
515 bind: default_bind(),
516 playground: false,
517 auth_enabled: true,
518 access_token_ttl: "15m".into(),
519 refresh_token_ttl: "30d".into(),
520 cors_origins: Vec::new(),
521 allowed_hosts: Vec::new(),
522 cookie_secure: false,
523 proxy_auth_header: String::new(),
524 proxy_auth_from: Vec::new(),
525 allow_organize: false,
526 setup_wizard: true,
527 }
528 }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
539#[serde(default)]
540pub struct SubsonicConfig {
541 pub enabled: bool,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub port: Option<u16>,
549 pub username: String,
551 #[serde(default, skip_serializing_if = "String::is_empty")]
554 pub password: String,
555 pub transcode: bool,
558 pub ffmpeg: String,
561}
562
563impl Default for SubsonicConfig {
564 fn default() -> Self {
565 Self {
566 enabled: false,
567 port: None,
568 username: "koan".into(),
569 password: String::new(),
570 transcode: true,
571 ffmpeg: "ffmpeg".into(),
572 }
573 }
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
581#[serde(default)]
582pub struct AuthConfig {
583 #[serde(skip_serializing_if = "String::is_empty")]
585 pub server: String,
586 #[serde(skip_serializing_if = "String::is_empty")]
589 pub refresh_token: String,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
596#[serde(default)]
597pub struct DevicesConfig {
598 pub nearby: bool,
604 pub discoverable: bool,
607 pub port: u16,
610 pub addresses: Vec<String>,
613 pub nearby_control: NearbyControl,
616 pub refused: Vec<String>,
619 pub keep_running: bool,
623}
624
625#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum NearbyControl {
629 #[default]
632 Full,
633 Playback,
636}
637
638impl Default for DevicesConfig {
639 fn default() -> Self {
640 Self {
641 nearby: true,
642 discoverable: true,
643 port: DEVICES_PORT,
644 addresses: Vec::new(),
645 nearby_control: NearbyControl::Full,
646 refused: Vec::new(),
647 keep_running: false,
648 }
649 }
650}
651
652pub const DEVICES_PORT: u16 = 5626;
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
660#[serde(default)]
661pub struct DspConfig {
662 pub enabled: bool,
664 pub profiles: Vec<DspProfile>,
665 #[serde(skip_serializing_if = "Vec::is_empty")]
668 pub autoeq_dismissed: Vec<String>,
669 #[serde(skip_serializing_if = "Vec::is_empty")]
673 pub tunings: Vec<DspOutputTuning>,
674 #[serde(skip_serializing_if = "Vec::is_empty")]
677 pub presets: Vec<DspOutputPreset>,
678 #[serde(skip_serializing_if = "Vec::is_empty")]
681 pub filters_only: Vec<String>,
682 #[serde(skip_serializing_if = "Vec::is_empty")]
687 pub removed: Vec<String>,
688}
689
690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
692pub struct DspOutputTuning {
693 pub device: String,
694 pub tuning: String,
695 #[serde(default = "on", skip_serializing_if = "is_on")]
697 pub on: bool,
698}
699
700fn on() -> bool {
701 true
702}
703
704fn is_on(on: &bool) -> bool {
705 *on
706}
707
708#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct DspOutputPreset {
711 pub device: String,
712 pub preset: String,
713}
714
715impl Default for DspConfig {
716 fn default() -> Self {
717 Self {
718 enabled: true,
719 profiles: Vec::new(),
720 autoeq_dismissed: Vec::new(),
721 tunings: Vec::new(),
722 presets: Vec::new(),
723 filters_only: Vec::new(),
724 removed: Vec::new(),
725 }
726 }
727}
728
729impl DspProfile {
730 pub fn measured(&self) -> Option<&DspMeasurement> {
733 self.fitted.as_ref().or(self.measurement.as_ref())
734 }
735}
736
737impl DspConfig {
738 pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
742 if !self.enabled {
743 return None;
744 }
745 let only = self.filters_only.iter().any(|d| d == device);
746 self.profiles
747 .iter()
748 .find(|p| p.devices.iter().any(|d| d == device))
749 .filter(|p| {
750 !only || !crate::audio::dsp::profiles::shown_role(p, &self.profiles).corrects()
751 })
752 }
753}
754
755#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
756#[serde(default)]
757pub struct DspProfile {
758 pub name: String,
759 pub devices: Vec<String>,
761 #[serde(skip_serializing_if = "Option::is_none")]
764 pub preamp_db: Option<f64>,
765 pub filters: Vec<DspFilter>,
767 pub impulses: Vec<PathBuf>,
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
774 pub source: Vec<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub target: Option<DspTarget>,
779 #[serde(default, skip_serializing_if = "Vec::is_empty")]
783 pub layers: Vec<DspLayer>,
784 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
789 pub group: bool,
790 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
794 pub preset: bool,
795 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
798 pub filters_only: bool,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub scope: Option<DspScope>,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub uid: Option<String>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub origin: Option<String>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub role: Option<DspRole>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub measurement: Option<DspMeasurement>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub fitted: Option<DspMeasurement>,
831 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub tuned_for: Option<String>,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub original: Option<DspOriginal>,
839}
840
841#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
843pub struct DspOriginal {
844 pub filters: Vec<DspFilter>,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub preamp_db: Option<f64>,
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
852#[serde(rename_all = "snake_case")]
853pub enum DspRole {
854 Correction,
857 Tuning,
859 Baked,
864}
865
866impl DspRole {
867 pub fn corrects(self) -> bool {
869 matches!(self, Self::Correction | Self::Baked)
870 }
871}
872
873#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
875pub struct DspMeasurement {
876 pub ear: DspEar,
877 pub target: String,
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(rename_all = "snake_case")]
883pub enum DspEar {
884 In,
885 Over,
886 Speaker,
887}
888
889pub mod dsp_bounds {
894 use std::ops::RangeInclusive;
895 pub const FREQ_HZ: RangeInclusive<f64> = 1.0..=48_000.0;
896 pub const GAIN_DB: RangeInclusive<f64> = -30.0..=30.0;
897 pub const Q: RangeInclusive<f64> = 0.01..=100.0;
898 pub const DELAY_MS: RangeInclusive<f64> = 0.0..=2_000.0;
899 pub const DELAY_SAMPLES: RangeInclusive<f64> = 0.0..=768_000.0;
901 pub const MIX_GAIN: f64 = 31.63;
903 pub const CHANNELS: u16 = 64;
904 pub const BANDS_PER_CHANNEL: usize = 64;
906 pub const FILTERS: usize = 256;
907 pub const GRAPHIC_POINTS: usize = 2_048;
908 pub const LAYERS: usize = 32;
909 pub const NAME: usize = 128;
910
911 pub const CHAIN_DELAY_MS: f64 = 2_000.0;
919 pub const CHAIN_GRAPHICS: usize = 2;
923 pub const CHAIN_MIXES: usize = 8;
925 pub const IMPULSE_TAPS: usize = 262_145;
929}
930
931pub fn budget(filters: &mut Vec<DspFilter>) -> Vec<String> {
935 use dsp_bounds as b;
936 let mut notes = Vec::new();
937 let mut note = |n: String| {
938 if !notes.contains(&n) {
939 notes.push(n);
940 }
941 };
942 let channels = b::CHANNELS as usize;
943 let mut delay = vec![0.0f64; channels];
944 let mut graphics = vec![0usize; channels];
945 let mut mixes = 0;
946 let on = |cs: &[u16], c: usize| cs.is_empty() || cs.contains(&(c as u16));
947 filters.retain_mut(|f| match f {
948 DspFilter::Delay(d) => {
949 let ms = d.ms + d.samples / 44.1;
950 let room = (0..channels)
951 .filter(|c| on(&d.channels, *c))
952 .map(|c| b::CHAIN_DELAY_MS - delay[c])
953 .fold(b::CHAIN_DELAY_MS, f64::min)
954 .max(0.0);
955 if ms > room {
956 note(format!(
957 "delays shortened to {} ms in all",
958 b::CHAIN_DELAY_MS
959 ));
960 if room <= 0.0 {
961 return false;
962 }
963 let scale = room / ms;
965 d.ms *= scale;
966 d.samples = (d.samples * scale).floor();
967 }
968 let ms = d.ms + d.samples / 44.1;
969 for (c, total) in delay.iter_mut().enumerate() {
970 if on(&d.channels, c) {
971 *total += ms;
972 }
973 }
974 true
975 }
976 DspFilter::Graphic(g) => {
977 let full =
978 (0..channels).any(|c| on(&g.channels, c) && graphics[c] == b::CHAIN_GRAPHICS);
979 if full {
980 note(format!(
981 "graphic EQs past {} a channel dropped",
982 b::CHAIN_GRAPHICS
983 ));
984 return false;
985 }
986 for (c, n) in graphics.iter_mut().enumerate() {
987 if on(&g.channels, c) {
988 *n += 1;
989 }
990 }
991 true
992 }
993 DspFilter::Mix(_) => {
994 mixes += 1;
995 if mixes > b::CHAIN_MIXES {
996 note(format!("mixes past {} dropped", b::CHAIN_MIXES));
997 return false;
998 }
999 true
1000 }
1001 DspFilter::Band(_) => true,
1002 });
1003 notes
1004}
1005
1006impl DspProfile {
1007 pub fn sanitize(&mut self) -> Vec<String> {
1015 use dsp_bounds as b;
1016 let mut notes: Vec<String> = Vec::new();
1017 let mut note = |n: String| {
1018 if !notes.contains(&n) {
1019 notes.push(n);
1020 }
1021 };
1022 let mut clamp = |what: &str, v: &mut f64, r: &std::ops::RangeInclusive<f64>, unit: &str| {
1023 let c = v.clamp(*r.start(), *r.end());
1024 if c != *v {
1025 note(format!(
1026 "{what} clamped to {}{unit}",
1027 if *v > *r.end() { r.end() } else { r.start() }
1028 ));
1029 *v = c;
1030 }
1031 };
1032 let finite = |v: f64| v.is_finite();
1033 let mut dropped = Vec::new();
1034
1035 let cleaned: String = self
1036 .name
1037 .chars()
1038 .filter(|c| !c.is_control())
1039 .take(b::NAME)
1040 .collect();
1041 let cleaned = if cleaned.trim().is_empty() {
1042 "Profile".to_owned()
1043 } else {
1044 cleaned
1045 };
1046 if cleaned != self.name {
1047 dropped.push("name shortened".to_owned());
1048 self.name = cleaned;
1049 }
1050 if let Some(db) = self.preamp_db.as_mut() {
1051 if finite(*db) {
1052 clamp("preamp", db, &b::GAIN_DB, " dB");
1053 } else {
1054 self.preamp_db = None;
1055 dropped.push("preamp dropped".to_owned());
1056 }
1057 }
1058 let bad_channel = |cs: &[u16]| cs.iter().any(|c| *c >= b::CHANNELS);
1059 let mut per_channel = [0usize; b::CHANNELS as usize];
1060 let before = self.filters.len();
1061 let mut kept = Vec::with_capacity(before.min(b::FILTERS));
1062 for mut filter in std::mem::take(&mut self.filters) {
1063 if kept.len() == b::FILTERS {
1064 dropped.push(format!("filters past {} dropped", b::FILTERS));
1065 break;
1066 }
1067 let keep = match &mut filter {
1068 DspFilter::Band(f) => {
1069 if ![f.freq, f.gain_db, f.q].into_iter().all(finite) || bad_channel(&f.channels)
1070 {
1071 false
1072 } else {
1073 let on =
1074 |c: usize| f.channels.is_empty() || f.channels.contains(&(c as u16));
1075 if (0..b::CHANNELS as usize)
1076 .any(|c| on(c) && per_channel[c] == b::BANDS_PER_CHANNEL)
1077 {
1078 dropped.push(format!(
1079 "bands past {} a channel dropped",
1080 b::BANDS_PER_CHANNEL
1081 ));
1082 continue;
1083 } else {
1084 for (c, n) in per_channel.iter_mut().enumerate() {
1085 if on(c) {
1086 *n += 1;
1087 }
1088 }
1089 clamp("a band's frequency", &mut f.freq, &b::FREQ_HZ, " Hz");
1090 clamp("a band's gain", &mut f.gain_db, &b::GAIN_DB, " dB");
1091 clamp("a band's Q", &mut f.q, &b::Q, "");
1092 true
1093 }
1094 }
1095 }
1096 DspFilter::Delay(d) => {
1097 if !finite(d.ms) || !finite(d.samples) || bad_channel(&d.channels) {
1098 false
1099 } else {
1100 clamp("delay", &mut d.ms, &b::DELAY_MS, " ms");
1101 clamp("delay", &mut d.samples, &b::DELAY_SAMPLES, " samples");
1102 true
1103 }
1104 }
1105 DspFilter::Mix(m) => {
1106 m.outputs.truncate(b::CHANNELS as usize);
1107 for o in &mut m.outputs {
1108 o.retain(|(c, g)| *c < b::CHANNELS && finite(*g));
1109 o.truncate(b::CHANNELS as usize);
1110 for (_, g) in o.iter_mut() {
1111 clamp("a mix's gain", g, &(-b::MIX_GAIN..=b::MIX_GAIN), "");
1112 }
1113 }
1114 true
1115 }
1116 DspFilter::Graphic(g) => {
1117 if bad_channel(&g.channels) {
1118 false
1119 } else {
1120 g.points.retain(|(hz, db)| finite(*hz) && finite(*db));
1121 if g.points.len() > b::GRAPHIC_POINTS {
1122 g.points.truncate(b::GRAPHIC_POINTS);
1123 dropped.push(format!(
1124 "graphic EQ points past {} dropped",
1125 b::GRAPHIC_POINTS
1126 ));
1127 }
1128 for (hz, db) in &mut g.points {
1129 clamp(
1130 "a graphic EQ's frequency",
1131 hz,
1132 &(0.0..=*b::FREQ_HZ.end()),
1133 " Hz",
1134 );
1135 clamp("a graphic EQ's gain", db, &b::GAIN_DB, " dB");
1136 }
1137 true
1138 }
1139 }
1140 };
1141 if keep {
1142 kept.push(filter);
1143 } else {
1144 dropped.push("a filter that could not play dropped".to_owned());
1145 }
1146 }
1147 self.filters = kept;
1148 for n in budget(&mut self.filters) {
1149 dropped.push(n);
1150 }
1151 if self.layers.len() > b::LAYERS {
1152 self.layers.truncate(b::LAYERS);
1153 dropped.push(format!("layers past {} dropped", b::LAYERS));
1154 }
1155 self.layers.retain(|l| l.profile.chars().count() <= b::NAME);
1156 if self
1157 .origin
1158 .as_ref()
1159 .is_some_and(|o| o.chars().count() > b::NAME)
1160 {
1161 self.origin = None;
1162 }
1163 if self
1164 .tuned_for
1165 .as_ref()
1166 .is_some_and(|t| t.chars().count() > b::NAME)
1167 {
1168 self.tuned_for = None;
1169 dropped.push("tuned-for target dropped".to_owned());
1170 }
1171 if self
1172 .measurement
1173 .as_ref()
1174 .is_some_and(|m| m.target.chars().count() > b::NAME)
1175 {
1176 self.measurement = None;
1177 dropped.push("measurement's target dropped".to_owned());
1178 }
1179 if self
1180 .fitted
1181 .as_ref()
1182 .is_some_and(|m| m.target.chars().count() > b::NAME)
1183 {
1184 self.fitted = None;
1185 dropped.push("measurement's target dropped".to_owned());
1186 }
1187 if let Some(t) = &mut self.target {
1188 let long = |s: &str| s.chars().count() > b::NAME;
1189 if long(&t.made_for) {
1190 self.target = None;
1191 dropped.push("target dropped".to_owned());
1192 } else if t.chosen.as_deref().is_some_and(long) {
1193 t.chosen = None;
1194 dropped.push("chosen target dropped".to_owned());
1195 }
1196 }
1197 if let Some(o) = self.original.take() {
1199 let mut kept = DspProfile {
1200 filters: o.filters,
1201 preamp_db: o.preamp_db,
1202 ..Default::default()
1203 };
1204 kept.sanitize();
1205 self.original = Some(DspOriginal {
1206 filters: kept.filters,
1207 preamp_db: kept.preamp_db,
1208 });
1209 }
1210 for d in dropped {
1211 note(d);
1212 }
1213 notes
1214 }
1215
1216 pub fn sanitized(&self) -> Self {
1218 let mut p = self.clone();
1219 p.sanitize();
1220 p
1221 }
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1226#[serde(rename_all = "snake_case")]
1227pub enum DspScope {
1228 Everywhere,
1230 Device,
1232}
1233
1234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1236pub struct DspLayer {
1237 pub profile: String,
1238 #[serde(default = "layer_on")]
1239 pub on: bool,
1240}
1241
1242fn layer_on() -> bool {
1243 true
1244}
1245
1246#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1248pub struct DspTarget {
1249 pub made_for: String,
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub chosen: Option<String>,
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1260#[serde(tag = "type", rename_all = "snake_case")]
1261pub enum DspFilter {
1262 Delay(Delay),
1263 Mix(Mix),
1264 Graphic(GraphicEq),
1265 #[serde(untagged)]
1266 Band(EqFilter),
1267}
1268
1269impl DspFilter {
1270 pub fn channels(&self) -> &[u16] {
1273 match self {
1274 Self::Band(f) => &f.channels,
1275 Self::Delay(d) => &d.channels,
1276 Self::Graphic(g) => &g.channels,
1277 Self::Mix(_) => &[],
1278 }
1279 }
1280
1281 pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
1282 match self {
1283 Self::Band(f) => Some(&mut f.channels),
1284 Self::Delay(d) => Some(&mut d.channels),
1285 Self::Graphic(g) => Some(&mut g.channels),
1286 Self::Mix(_) => None,
1287 }
1288 }
1289}
1290
1291impl From<EqFilter> for DspFilter {
1292 fn from(f: EqFilter) -> Self {
1293 Self::Band(f)
1294 }
1295}
1296
1297#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1299#[serde(default)]
1300pub struct Delay {
1301 #[serde(skip_serializing_if = "is_zero")]
1302 pub ms: f64,
1303 #[serde(skip_serializing_if = "is_zero")]
1304 pub samples: f64,
1305 #[serde(skip_serializing_if = "std::ops::Not::not")]
1308 pub subsample: bool,
1309 #[serde(skip_serializing_if = "Vec::is_empty")]
1310 pub channels: Vec<u16>,
1311}
1312
1313fn is_zero(v: &f64) -> bool {
1314 *v == 0.0
1315}
1316
1317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1321pub struct Mix {
1322 pub outputs: Vec<Vec<(u16, f64)>>,
1323}
1324
1325#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1329pub struct GraphicEq {
1330 pub points: Vec<(f64, f64)>,
1331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1332 pub channels: Vec<u16>,
1333}
1334
1335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1337pub struct EqFilter {
1338 #[serde(rename = "type")]
1339 pub kind: EqFilterKind,
1340 pub freq: f64,
1342 #[serde(default)]
1343 pub gain_db: f64,
1344 #[serde(default = "default_q")]
1345 pub q: f64,
1346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1348 pub channels: Vec<u16>,
1349}
1350
1351fn default_q() -> f64 {
1352 std::f64::consts::FRAC_1_SQRT_2
1353}
1354
1355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1356#[serde(rename_all = "snake_case")]
1357pub enum EqFilterKind {
1358 Peaking,
1359 LowShelf,
1360 HighShelf,
1361 LowPass,
1362 HighPass,
1363 Notch,
1364 BandPass,
1365 AllPass,
1366 LowShelfFirstOrder,
1369 HighShelfFirstOrder,
1370 LowPassFirstOrder,
1371 HighPassFirstOrder,
1372 AllPassFirstOrder,
1373 Gain,
1375}
1376
1377impl EqFilterKind {
1378 pub fn name(self) -> &'static str {
1380 match self {
1381 Self::Peaking => "peaking",
1382 Self::LowShelf => "low_shelf",
1383 Self::HighShelf => "high_shelf",
1384 Self::LowPass => "low_pass",
1385 Self::HighPass => "high_pass",
1386 Self::Notch => "notch",
1387 Self::BandPass => "band_pass",
1388 Self::AllPass => "all_pass",
1389 Self::LowShelfFirstOrder => "low_shelf_first_order",
1390 Self::HighShelfFirstOrder => "high_shelf_first_order",
1391 Self::LowPassFirstOrder => "low_pass_first_order",
1392 Self::HighPassFirstOrder => "high_pass_first_order",
1393 Self::AllPassFirstOrder => "all_pass_first_order",
1394 Self::Gain => "gain",
1395 }
1396 }
1397}
1398
1399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1401pub enum Layer {
1402 Shared,
1404 Machine,
1406}
1407
1408pub fn layer_of(path: &str) -> Layer {
1418 match path {
1419 "remote.password"
1421 | "remote.api_key"
1422 | "remote.device_key"
1423 | "subsonic.password"
1424 | "auth.refresh_token"
1425 | "push.key"
1426 | "push.key_path"
1427 | "push.key_id"
1428 | "push.team_id"
1429 | "lastfm.api_key"
1430 | "lastfm.secret"
1431 | "library.folders"
1433 | "remote.enabled"
1434 | "remote.url"
1435 | "remote.username"
1436 | "remote.cache_dir"
1437 | "remote.cache_limit"
1438 | "remote.play_queue"
1440 | "playback.output_device"
1442 | "playback.renderer"
1443 | "playback.renderer_name"
1444 | "playback.renderers"
1445 | "playback.muted"
1446 | "playback.rate_switch_lead_in_ms"
1447 | "subsonic.enabled"
1450 | "subsonic.port"
1451 | "subsonic.username"
1452 | "subsonic.transcode"
1453 | "subsonic.ffmpeg"
1454 | "devices.nearby"
1456 | "devices.discoverable"
1457 | "devices.port"
1458 | "devices.addresses"
1459 | "devices.nearby_control"
1460 | "devices.refused"
1461 | "devices.keep_running"
1462 | "auth.server"
1464 | "playback.art_size"
1466 | "playback.art_graphics"
1467 | "playback.tui_theme"
1468 | "playback.queue_grouped"
1469 | "visualizer.enabled"
1470 | "visualizer.mode"
1471 | "visualizer.matrix_overlay"
1472 | "visualizer.bass_shake"
1473 | "appearance.rainbow"
1474 | "appearance.text_size" => Layer::Machine,
1476 p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
1478 _ => Layer::Shared,
1479 }
1480}
1481
1482type ConfigStamp = (PathBuf, Option<SystemTime>, Option<SystemTime>);
1489
1490type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
1491
1492static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
1493 LazyLock::new(|| parking_lot::RwLock::new(None));
1494
1495static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1500
1501fn config_stamp() -> ConfigStamp {
1502 let (base, local) = stamp_of(&config_file_path(), &config_local_file_path());
1503 (config_dir(), base, local)
1504}
1505
1506fn stamp_of(base: &Path, local: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
1508 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
1509 (mtime(base), mtime(local))
1510}
1511
1512impl Config {
1513 fn figment() -> Figment {
1519 let base_path = config_file_path();
1520 let local_path = config_local_file_path();
1521
1522 Figment::from(Serialized::defaults(Config::default()))
1523 .merge(Toml::file(&base_path))
1524 .merge(Toml::file(&local_path))
1525 .merge(Env::prefixed("KOAN_").split("__"))
1526 }
1527
1528 pub fn load() -> Result<Self, ConfigError> {
1530 let cfg: Self = Self::figment()
1531 .extract()
1532 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
1533
1534 check_secrets_in_git();
1537
1538 Ok(cfg)
1539 }
1540
1541 pub fn load_or_default() -> Self {
1546 (*Self::cached()).clone()
1547 }
1548
1549 pub fn cached() -> Arc<Config> {
1556 let generation = CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst);
1557 let stamp = config_stamp();
1558 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
1559 && *seen == stamp
1560 {
1561 return cfg.clone();
1562 }
1563
1564 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
1565 log::warn!("failed to load config, using defaults: {}", e);
1566 Self::default()
1567 }));
1568 let mut cache = CONFIG_CACHE.write();
1573 if CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == generation
1574 && config_stamp() == stamp
1575 {
1576 *cache = Some((stamp, cfg.clone()));
1577 }
1578 cfg
1579 }
1580
1581 pub fn invalidate_cache() {
1585 let mut cache = CONFIG_CACHE.write();
1586 CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1587 *cache = None;
1588 }
1589
1590 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1592 let contents = fs::read_to_string(path)?;
1593 let config: Config = toml::from_str(&contents)?;
1594 Ok(config)
1595 }
1596
1597 fn from_files() -> Result<Self, ConfigError> {
1603 Figment::from(Serialized::defaults(Config::default()))
1604 .merge(Toml::file(config_file_path()))
1605 .merge(Toml::file(config_local_file_path()))
1606 .extract()
1607 .map_err(|e| ConfigError::Figment(Box::new(e)))
1608 }
1609
1610 pub fn set_in_files(path: &str) -> bool {
1613 [config_file_path(), config_local_file_path()]
1614 .iter()
1615 .any(|file| {
1616 let Ok(doc) = read_document(file) else {
1617 return true;
1619 };
1620 let mut item = doc.as_item();
1621 for key in path.split('.') {
1622 match item.get(key) {
1623 Some(next) => item = next,
1624 None => return false,
1625 }
1626 }
1627 true
1628 })
1629 }
1630
1631 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
1643 where
1644 F: FnOnce(&mut Config),
1645 {
1646 static ONE_AT_A_TIME: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
1650 let _one = ONE_AT_A_TIME.lock();
1651 let before = Self::from_files()?;
1652 let mut after = before.clone();
1653 mutate(&mut after);
1654
1655 let mut changes = Vec::new();
1656 diff_into(
1657 "",
1658 &toml::Value::try_from(&before)?,
1659 &toml::Value::try_from(&after)?,
1660 &mut changes,
1661 );
1662 if changes.is_empty() {
1663 return Ok(());
1664 }
1665
1666 let base_path = config_file_path();
1667 let local_path = config_local_file_path();
1668 let mut base = read_document(&base_path)?;
1669 let mut local = read_document(&local_path)?;
1670
1671 for (path, value) in &changes {
1672 let (target, other) = match layer_of(path) {
1673 Layer::Shared => (&mut base, &mut local),
1674 Layer::Machine => (&mut local, &mut base),
1675 };
1676 match value {
1677 Some(v) => doc_set(target, path, v),
1678 None => doc_remove(target, path),
1679 }
1680 doc_remove(other, path);
1681 }
1682
1683 write_document(&base_path, &base, false)?;
1684 write_document(&local_path, &local, true)?;
1685 Self::invalidate_cache();
1686 Ok(())
1687 }
1688
1689 pub fn cache_dir(&self) -> PathBuf {
1691 self.remote
1692 .cache_dir
1693 .clone()
1694 .unwrap_or_else(default_cache_dir)
1695 }
1696
1697 pub fn cache_limit_bytes(&self) -> Option<u64> {
1699 self.remote
1700 .cache_limit
1701 .as_deref()
1702 .and_then(parse_size_bytes)
1703 }
1704}
1705
1706fn diff_into(
1711 prefix: &str,
1712 before: &toml::Value,
1713 after: &toml::Value,
1714 out: &mut Vec<(String, Option<toml::Value>)>,
1715) {
1716 let (b, a) = match (before.as_table(), after.as_table()) {
1717 (Some(b), Some(a)) => (b, a),
1718 _ => {
1719 if before != after {
1720 out.push((prefix.to_string(), Some(after.clone())));
1721 }
1722 return;
1723 }
1724 };
1725
1726 let empty = toml::Value::Table(toml::map::Map::new());
1727 for key in b
1728 .keys()
1729 .chain(a.keys())
1730 .collect::<std::collections::BTreeSet<_>>()
1731 {
1732 let path = if prefix.is_empty() {
1733 key.clone()
1734 } else {
1735 format!("{prefix}.{key}")
1736 };
1737 match (b.get(key), a.get(key)) {
1738 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
1739 (None, Some(av)) => diff_into(&path, &empty, av, out),
1742 (Some(_), None) => out.push((path, None)),
1743 (None, None) => unreachable!("key came from one of the two tables"),
1744 }
1745 }
1746}
1747
1748fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
1749 let Ok(contents) = fs::read_to_string(path) else {
1750 return Ok(toml_edit::DocumentMut::new());
1751 };
1752 contents
1753 .parse::<toml_edit::DocumentMut>()
1754 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
1755}
1756
1757fn write_document(
1760 path: &Path,
1761 doc: &toml_edit::DocumentMut,
1762 secret: bool,
1763) -> Result<(), ConfigError> {
1764 let contents = doc.to_string();
1765 if contents.trim().is_empty() && !path.exists() {
1766 return Ok(());
1767 }
1768 if let Some(parent) = path.parent() {
1769 fs::create_dir_all(parent)?;
1770 }
1771 let target = link_target(path);
1774 let temp = unique_beside(&target);
1775 let written = write_new(&temp, &target, contents.as_bytes(), secret)
1776 .and_then(|()| fs::rename(&temp, &target));
1777 if let Err(e) = written {
1778 let _ = fs::remove_file(&temp);
1779 return Err(e.into());
1780 }
1781 #[cfg(target_os = "tvos")]
1782 kept::written(path, contents.as_bytes());
1783 Ok(())
1784}
1785
1786fn link_target(path: &Path) -> PathBuf {
1789 let mut target = path.to_path_buf();
1790 for _ in 0..40 {
1791 let Ok(next) = fs::read_link(&target) else {
1792 break;
1793 };
1794 target = match target.parent() {
1795 Some(parent) if next.is_relative() => parent.join(next),
1796 _ => next,
1797 };
1798 }
1799 target
1800}
1801
1802fn unique_beside(target: &Path) -> PathBuf {
1805 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1806 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1807 let name = target
1808 .file_name()
1809 .map(|n| n.to_string_lossy().into_owned())
1810 .unwrap_or_default();
1811 target.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
1812}
1813
1814fn write_new(temp: &Path, target: &Path, contents: &[u8], secret: bool) -> std::io::Result<()> {
1818 use std::io::Write as _;
1819 let mut options = fs::OpenOptions::new();
1820 options.write(true).create_new(true);
1821 #[cfg(unix)]
1822 let kept_mode = {
1823 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1824 let kept_mode = (!secret)
1825 .then(|| fs::metadata(target).ok())
1826 .flatten()
1827 .map(|m| m.permissions().mode() & 0o7777);
1828 options.mode(if secret {
1829 0o600
1830 } else {
1831 kept_mode.unwrap_or(0o666)
1832 });
1833 kept_mode
1834 };
1835 #[cfg(not(unix))]
1836 let _ = (target, secret);
1837 let mut file = options.open(temp)?;
1838 file.write_all(contents)?;
1839 #[cfg(unix)]
1842 if let Some(mode) = kept_mode {
1843 use std::os::unix::fs::PermissionsExt;
1844 file.set_permissions(fs::Permissions::from_mode(mode))?;
1845 }
1846 Ok(())
1847}
1848
1849fn implicit_table() -> toml_edit::Item {
1850 let mut table = toml_edit::Table::new();
1851 table.set_implicit(true);
1854 toml_edit::Item::Table(table)
1855}
1856
1857fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
1858 let segments: Vec<&str> = path.split('.').collect();
1859 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1860
1861 let mut table = doc.as_table_mut();
1862 for segment in parents {
1863 let item = table.entry(segment).or_insert_with(implicit_table);
1864 if !item.is_table() {
1867 *item = implicit_table();
1868 }
1869 table = item.as_table_mut().expect("just ensured it is a table");
1870 }
1871 match table.get_mut(last) {
1874 Some(existing) => *existing = to_edit_item(value),
1875 None => {
1876 table.insert(last, to_edit_item(value));
1877 }
1878 }
1879}
1880
1881fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
1884 let toml::Value::Array(items) = value else {
1885 return toml_edit::value(to_edit_value(value));
1886 };
1887 if items.is_empty() || !items.iter().all(toml::Value::is_table) {
1888 return toml_edit::value(to_edit_value(value));
1889 }
1890 let mut sections = toml_edit::ArrayOfTables::new();
1891 for item in items {
1892 let mut section = toml_edit::Table::new();
1893 for (k, v) in item.as_table().expect("checked above") {
1894 let mut v = to_edit_value(v);
1895 if let toml_edit::Value::Array(list) = &mut v
1896 && list.iter().any(toml_edit::Value::is_inline_table)
1897 {
1898 for entry in list.iter_mut() {
1899 entry.decor_mut().set_prefix("\n ");
1900 if let Some(t) = entry.as_inline_table_mut() {
1901 t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1902 }
1903 }
1904 list.set_trailing("\n");
1905 list.set_trailing_comma(true);
1906 }
1907 section.insert(k, toml_edit::value(v));
1908 }
1909 section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1910 sections.push(section);
1911 }
1912 toml_edit::Item::ArrayOfTables(sections)
1913}
1914
1915fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
1917 let k = key.get();
1918 (if k == "name" || k == "type" { 0 } else { 1 }, k)
1919}
1920
1921fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
1922 let segments: Vec<&str> = path.split('.').collect();
1923 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1924
1925 let mut table = doc.as_table_mut();
1926 for segment in parents {
1927 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
1928 Some(child) => table = child,
1929 None => return,
1930 }
1931 }
1932 table.remove(last);
1935}
1936
1937fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
1938 match value {
1939 toml::Value::String(s) => s.as_str().into(),
1940 toml::Value::Integer(i) => (*i).into(),
1941 toml::Value::Float(f) => (*f).into(),
1942 toml::Value::Boolean(b) => (*b).into(),
1943 toml::Value::Datetime(d) => d.to_string().into(),
1944 toml::Value::Array(items) => items
1945 .iter()
1946 .map(to_edit_value)
1947 .collect::<toml_edit::Array>()
1948 .into(),
1949 toml::Value::Table(t) => {
1950 let mut inline = toml_edit::InlineTable::new();
1951 for (k, v) in t {
1952 inline.insert(k, to_edit_value(v));
1953 }
1954 inline.into()
1955 }
1956 }
1957}
1958
1959pub fn create_cache_dir(dir: &std::path::Path) -> std::io::Result<()> {
1963 std::fs::create_dir_all(dir)?;
1964 let gitignore = dir.join(".gitignore");
1965 if !gitignore.exists() {
1966 std::fs::write(gitignore, "*\n")?;
1967 }
1968 Ok(())
1969}
1970
1971pub fn config_dir() -> PathBuf {
1980 if let Some(dir) = CONFIG_DIR.read().clone() {
1981 return dir;
1982 }
1983 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
1984 return PathBuf::from(dir);
1985 }
1986 platform_config_dir()
1987}
1988
1989#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
1990fn platform_config_dir() -> PathBuf {
1991 dirs::home_dir()
1992 .unwrap_or_else(|| PathBuf::from("."))
1993 .join(".config")
1994 .join("koan")
1995}
1996
1997#[cfg(target_os = "ios")]
2001fn platform_config_dir() -> PathBuf {
2002 ios_library().join("Application Support").join("koan")
2003}
2004
2005#[cfg(target_os = "tvos")]
2010fn platform_config_dir() -> PathBuf {
2011 let dir = ios_library().join("Caches").join("koan-config");
2012 kept::restore(&dir);
2013 dir
2014}
2015
2016#[cfg(target_os = "tvos")]
2025mod kept {
2026 use std::fs;
2027 use std::os::unix::fs::PermissionsExt;
2028 use std::path::Path;
2029 use std::sync::Once;
2030
2031 use core_foundation::base::TCFType;
2032 use core_foundation::data::{CFData, CFDataRef};
2033 use core_foundation::string::CFString;
2034 use core_foundation_sys::base::{CFGetTypeID, CFRelease};
2035 use core_foundation_sys::data::CFDataGetTypeID;
2036 use core_foundation_sys::preferences::{
2037 CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesSetAppValue,
2038 kCFPreferencesCurrentApplication,
2039 };
2040
2041 const FILES: [&str; 2] = ["config.toml", "config.local.toml"];
2042
2043 pub(super) fn restore(dir: &Path) {
2047 static ONCE: Once = Once::new();
2048 ONCE.call_once(|| {
2049 for name in FILES {
2050 let path = dir.join(name);
2051 match fs::read(&path) {
2052 Ok(contents) => save(name, &contents),
2053 Err(_) => {
2054 let Some(contents) = load(name) else { continue };
2055 let _ = fs::create_dir_all(dir);
2056 if fs::write(&path, &contents).is_ok() {
2057 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
2058 log::info!("config: restored {name} after the system cleared it");
2059 }
2060 }
2061 }
2062 }
2063 });
2064 }
2065
2066 pub(super) fn written(path: &Path, contents: &[u8]) {
2069 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2070 return;
2071 };
2072 if FILES.contains(&name) && path.parent() == Some(super::platform_config_dir().as_path()) {
2073 save(name, contents);
2074 }
2075 }
2076
2077 fn save(name: &str, contents: &[u8]) {
2078 let key = CFString::new(name);
2079 let data = CFData::from_buffer(contents);
2080 unsafe {
2083 CFPreferencesSetAppValue(
2084 key.as_concrete_TypeRef(),
2085 data.as_CFTypeRef(),
2086 kCFPreferencesCurrentApplication,
2087 );
2088 CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
2089 }
2090 }
2091
2092 fn load(name: &str) -> Option<Vec<u8>> {
2093 let key = CFString::new(name);
2094 unsafe {
2097 let value = CFPreferencesCopyAppValue(
2098 key.as_concrete_TypeRef(),
2099 kCFPreferencesCurrentApplication,
2100 );
2101 if value.is_null() {
2102 return None;
2103 }
2104 if CFGetTypeID(value) != CFDataGetTypeID() {
2105 CFRelease(value);
2106 return None;
2107 }
2108 Some(
2109 CFData::wrap_under_create_rule(value as CFDataRef)
2110 .bytes()
2111 .to_vec(),
2112 )
2113 }
2114 }
2115}
2116
2117#[cfg(any(target_os = "ios", target_os = "tvos"))]
2118fn ios_library() -> PathBuf {
2119 dirs::home_dir()
2120 .unwrap_or_else(|| PathBuf::from("."))
2121 .join("Library")
2122}
2123
2124fn default_cache_dir() -> PathBuf {
2131 #[cfg(any(target_os = "ios", target_os = "tvos"))]
2132 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
2133 return ios_library().join("Caches").join("koan");
2134 }
2135 config_dir().join("cache")
2136}
2137
2138pub fn set_config_dir(dir: impl Into<PathBuf>) {
2145 *CONFIG_DIR.write() = Some(dir.into());
2146 Config::invalidate_cache();
2147}
2148
2149pub fn isolate_config_for_tests() {
2165 let _one = SWITCHING.lock().unwrap_or_else(|e| e.into_inner());
2166 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
2167 let _ = fs::create_dir_all(&dir);
2168 set_config_dir(dir);
2169}
2170
2171#[doc(hidden)]
2175pub static SWITCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2176
2177static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
2178 LazyLock::new(|| parking_lot::RwLock::new(None));
2179
2180pub fn config_file_path() -> PathBuf {
2182 config_dir().join("config.toml")
2183}
2184
2185pub fn config_local_file_path() -> PathBuf {
2187 config_dir().join("config.local.toml")
2188}
2189
2190pub fn db_path() -> PathBuf {
2192 config_dir().join("koan.db")
2193}
2194
2195const LOG_LIMIT: u64 = 16 * 1024 * 1024;
2198const LOG_CHECK_EVERY: u32 = 4096;
2201
2202#[derive(Default)]
2204pub struct LogFile {
2205 file: Option<fs::File>,
2206 lines: u32,
2207}
2208
2209impl LogFile {
2210 pub fn write(&mut self, line: std::fmt::Arguments) {
2211 use std::io::Write as _;
2212 if self.file.is_none() {
2213 self.file = open_log();
2214 }
2215 let Some(file) = self.file.as_mut() else {
2216 return;
2217 };
2218 let _ = writeln!(file, "{line}");
2219 self.lines = self.lines.wrapping_add(1);
2220 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
2221 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
2222 {
2223 self.file = open_log();
2224 }
2225 }
2226
2227 pub fn flush(&mut self) {
2228 if let Some(file) = self.file.as_mut() {
2229 let _ = std::io::Write::flush(file);
2230 }
2231 }
2232}
2233
2234fn open_log() -> Option<fs::File> {
2240 let dir = config_dir();
2241 fs::create_dir_all(&dir).ok()?;
2242 let path = dir.join("koan.log");
2243 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
2244 let _ = fs::rename(&path, dir.join("koan.log.1"));
2245 }
2246 fs::OpenOptions::new()
2247 .create(true)
2248 .append(true)
2249 .open(path)
2250 .ok()
2251}
2252
2253fn check_secrets_in_git() {
2260 static ONCE: Once = Once::new();
2261 ONCE.call_once(scan_for_tracked_secrets);
2262}
2263
2264fn scan_for_tracked_secrets() {
2265 let sensitive_fields = ["password", "api_key", "refresh_token", "secret"];
2266
2267 for (label, path) in [
2268 ("config.toml", config_file_path()),
2269 ("config.local.toml", config_local_file_path()),
2270 ] {
2271 let Ok(contents) = std::fs::read_to_string(&path) else {
2272 continue;
2273 };
2274
2275 let has_secrets = sensitive_fields.iter().any(|field| {
2277 contents.lines().any(|line| {
2278 let line = line.trim();
2279 if let Some(rest) = line.strip_prefix(field) {
2280 let rest = rest.trim_start();
2281 if let Some(value) = rest.strip_prefix('=') {
2282 let value = value.trim().trim_matches('"').trim_matches('\'');
2283 return !value.is_empty();
2284 }
2285 }
2286 false
2287 })
2288 });
2289
2290 if !has_secrets {
2291 continue;
2292 }
2293
2294 if is_tracked_by_git(&path) {
2296 eprintln!();
2297 eprintln!("╔══════════════════════════════════════════════════════════════╗");
2298 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
2299 eprintln!("╠══════════════════════════════════════════════════════════════╣");
2300 eprintln!("║ ║");
2301 eprintln!("║ File: {:<52} ║", path.display());
2302 eprintln!("║ ║");
2303 eprintln!("║ Your password is in version control. You should: ║");
2304 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
2305 eprintln!("║ 2. Add it to .gitignore ║");
2306 eprintln!("║ 3. Rotate your credentials immediately ║");
2307 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
2308 eprintln!("║ `koan remote login` writes there for you ║");
2309 eprintln!("║ ║");
2310 eprintln!("╚══════════════════════════════════════════════════════════════╝");
2311 eprintln!();
2312 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
2313 }
2314 }
2315}
2316
2317fn is_tracked_by_git(path: &Path) -> bool {
2319 let Some(parent) = path.parent() else {
2320 return false;
2321 };
2322 std::process::Command::new("git")
2324 .args(["ls-files", "--error-unmatch"])
2325 .arg(path)
2326 .current_dir(parent)
2327 .stdout(std::process::Stdio::null())
2328 .stderr(std::process::Stdio::null())
2329 .status()
2330 .is_ok_and(|s| s.success())
2331}
2332
2333#[cfg(test)]
2334pub(crate) mod tests {
2335 use super::*;
2336 use std::fs;
2337
2338 fn tmp_dir() -> PathBuf {
2339 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
2340 fs::create_dir_all(&dir).unwrap();
2341 dir
2342 }
2343
2344 #[test]
2345 fn test_defaults() {
2346 let cfg = Config::default();
2347 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2348 assert!(!cfg.remote.enabled);
2349 }
2350
2351 #[test]
2352 fn test_roundtrip_toml() {
2353 let cfg = Config::default();
2354 let serialized = toml::to_string_pretty(&cfg).unwrap();
2355 let deserialized: Config = toml::from_str(&serialized).unwrap();
2356 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
2357 assert_eq!(
2358 deserialized.remote.download_workers,
2359 cfg.remote.download_workers
2360 );
2361 }
2362
2363 #[test]
2364 fn test_load_from_file() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let path = dir.path().join("config.toml");
2367 fs::write(
2368 &path,
2369 r#"
2370[library]
2371folders = ["/tmp/music"]
2372
2373[playback]
2374replaygain = "track"
2375"#,
2376 )
2377 .unwrap();
2378
2379 let cfg = Config::load_from(&path).unwrap();
2380 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
2381 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
2382 assert!(!cfg.remote.enabled);
2383 }
2384
2385 #[test]
2386 fn test_partial_toml_uses_defaults() {
2387 let dir = tempfile::tempdir().unwrap();
2388 let path = dir.path().join("partial.toml");
2389 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
2390
2391 let cfg = Config::load_from(&path).unwrap();
2392 assert_eq!(cfg.playback.target_fps, 30);
2393 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2394 }
2395
2396 #[test]
2397 fn test_figment_layered_loading() {
2398 let dir = tempfile::tempdir().unwrap();
2399 let base_path = dir.path().join("config.toml");
2400 let local_path = dir.path().join("config.local.toml");
2401
2402 fs::write(
2403 &base_path,
2404 r#"
2405[remote]
2406url = "https://base.example.com"
2407"#,
2408 )
2409 .unwrap();
2410 fs::write(
2411 &local_path,
2412 r#"
2413[remote]
2414enabled = true
2415url = "https://local.example.com"
2416username = "admin"
2417password = "secret"
2418"#,
2419 )
2420 .unwrap();
2421
2422 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2424 .merge(Toml::file(&base_path))
2425 .merge(Toml::file(&local_path))
2426 .extract()
2427 .unwrap();
2428
2429 assert!(cfg.remote.enabled);
2430 assert_eq!(cfg.remote.url, "https://local.example.com");
2431 assert_eq!(cfg.remote.username, "admin");
2432 assert_eq!(cfg.remote.password, "secret");
2433 }
2434
2435 #[test]
2436 fn test_figment_missing_keys_preserved() {
2437 let dir = tempfile::tempdir().unwrap();
2438 let base_path = dir.path().join("config.toml");
2439 let local_path = dir.path().join("config.local.toml");
2440
2441 fs::write(
2442 &base_path,
2443 r#"
2444[remote]
2445url = "https://keep.me"
2446username = "keepuser"
2447"#,
2448 )
2449 .unwrap();
2450 fs::write(
2451 &local_path,
2452 r#"
2453[remote]
2454password = "secret"
2455"#,
2456 )
2457 .unwrap();
2458
2459 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2460 .merge(Toml::file(&base_path))
2461 .merge(Toml::file(&local_path))
2462 .extract()
2463 .unwrap();
2464
2465 assert_eq!(cfg.remote.url, "https://keep.me");
2466 assert_eq!(cfg.remote.username, "keepuser");
2467 assert_eq!(cfg.remote.password, "secret");
2468 }
2469
2470 #[test]
2471 fn test_env_var_override() {
2472 let dir = tempfile::tempdir().unwrap();
2473 let base_path = dir.path().join("config.toml");
2474
2475 fs::write(
2476 &base_path,
2477 r#"
2478[remote]
2479url = "https://file.example.com"
2480"#,
2481 )
2482 .unwrap();
2483
2484 unsafe {
2486 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
2487 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
2488 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
2489 }
2490
2491 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2492 .merge(Toml::file(&base_path))
2493 .merge(Env::prefixed("KOAN_").split("__"))
2494 .extract()
2495 .unwrap();
2496
2497 assert_eq!(cfg.remote.url, "https://env.example.com");
2498 assert_eq!(cfg.remote.password, "env-secret");
2499 assert_eq!(cfg.graphql.port, 9999);
2500
2501 unsafe {
2503 std::env::remove_var("KOAN_REMOTE__URL");
2504 std::env::remove_var("KOAN_REMOTE__PASSWORD");
2505 std::env::remove_var("KOAN_GRAPHQL__PORT");
2506 }
2507 }
2508
2509 #[test]
2510 fn test_cache_dir_default() {
2511 let cfg = Config::default();
2512 assert!(cfg.cache_dir().ends_with("cache"));
2513 }
2514
2515 #[test]
2516 fn test_cache_dir_explicit() {
2517 let mut cfg = Config::default();
2518 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
2519 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
2520 }
2521
2522 #[test]
2523 fn test_organize_config_defaults() {
2524 let cfg = Config::default();
2525 assert!(cfg.organize.default.is_none());
2526 assert!(cfg.organize.patterns.is_empty());
2527 }
2528
2529 #[test]
2530 fn test_organize_config_from_toml() {
2531 let dir = tmp_dir();
2532 let path = dir.join("organize.toml");
2533 fs::write(
2534 &path,
2535 r#"
2536[organize]
2537default = "standard"
2538
2539[organize.patterns]
2540standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
2541va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
2542"#,
2543 )
2544 .unwrap();
2545
2546 let cfg = Config::load_from(&path).unwrap();
2547 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
2548 assert_eq!(cfg.organize.patterns.len(), 2);
2549 assert!(cfg.organize.patterns.contains_key("standard"));
2550 assert!(cfg.organize.patterns.contains_key("va-aware"));
2551
2552 fs::remove_dir_all(&dir).ok();
2553 }
2554
2555 #[test]
2556 fn test_figment_organize_patterns_merge() {
2557 let dir = tempfile::tempdir().unwrap();
2558 let base_path = dir.path().join("config.toml");
2559 let local_path = dir.path().join("config.local.toml");
2560
2561 fs::write(
2562 &base_path,
2563 r#"
2564[organize]
2565default = "standard"
2566
2567[organize.patterns]
2568standard = "base-pattern"
2569"#,
2570 )
2571 .unwrap();
2572 fs::write(
2573 &local_path,
2574 r#"
2575[organize]
2576default = "custom"
2577
2578[organize.patterns]
2579custom = "local-pattern"
2580"#,
2581 )
2582 .unwrap();
2583
2584 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2585 .merge(Toml::file(&base_path))
2586 .merge(Toml::file(&local_path))
2587 .extract()
2588 .unwrap();
2589
2590 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
2592 assert_eq!(cfg.organize.patterns.len(), 2);
2594 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
2595 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
2596 }
2597
2598 #[test]
2599 fn test_output_device_config_roundtrip() {
2600 let mut cfg = Config::default();
2601 cfg.playback.output_device = Some("My DAC".into());
2602
2603 let serialized = toml::to_string_pretty(&cfg).unwrap();
2604 let deserialized: Config = toml::from_str(&serialized).unwrap();
2605 assert_eq!(
2606 deserialized.playback.output_device.as_deref(),
2607 Some("My DAC")
2608 );
2609 }
2610
2611 #[test]
2612 fn test_output_device_config_default_is_none() {
2613 let cfg = Config::default();
2614 assert!(cfg.playback.output_device.is_none());
2615
2616 let serialized = toml::to_string_pretty(&cfg).unwrap();
2618 assert!(!serialized.contains("output_device"));
2619 let deserialized: Config = toml::from_str(&serialized).unwrap();
2620 assert!(deserialized.playback.output_device.is_none());
2621 }
2622
2623 #[test]
2624 fn test_output_device_config_from_toml() {
2625 let dir = tempfile::tempdir().unwrap();
2626 let path = dir.path().join("config.toml");
2627 fs::write(
2628 &path,
2629 r#"
2630[playback]
2631output_device = "External Speakers"
2632"#,
2633 )
2634 .unwrap();
2635
2636 let cfg = Config::load_from(&path).unwrap();
2637 assert_eq!(
2638 cfg.playback.output_device.as_deref(),
2639 Some("External Speakers")
2640 );
2641 }
2642
2643 #[test]
2644 fn test_graphql_bind_defaults_to_localhost() {
2645 let cfg = GraphqlConfig::default();
2646 assert_eq!(
2647 cfg.bind,
2648 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2649 );
2650 }
2651
2652 #[test]
2653 fn test_graphql_bind_from_toml() {
2654 let toml_str = r#"
2655[graphql]
2656bind = "0.0.0.0"
2657port = 5000
2658"#;
2659 let cfg: Config = toml::from_str(toml_str).unwrap();
2660 assert_eq!(
2661 cfg.graphql.bind,
2662 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
2663 );
2664 assert_eq!(cfg.graphql.port, 5000);
2665 }
2666
2667 #[test]
2668 fn test_graphql_bind_omitted_defaults_to_localhost() {
2669 let toml_str = r#"
2670[graphql]
2671port = 4000
2672"#;
2673 let cfg: Config = toml::from_str(toml_str).unwrap();
2674 assert_eq!(
2675 cfg.graphql.bind,
2676 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2677 );
2678 }
2679
2680 #[test]
2681 fn test_organize_config_roundtrip() {
2682 let mut cfg = Config::default();
2683 cfg.organize.default = Some("standard".into());
2684 cfg.organize
2685 .patterns
2686 .insert("standard".into(), "%artist%/%title%".into());
2687
2688 let serialized = toml::to_string_pretty(&cfg).unwrap();
2689 let deserialized: Config = toml::from_str(&serialized).unwrap();
2690 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
2691 assert_eq!(
2692 deserialized.organize.patterns["standard"],
2693 "%artist%/%title%"
2694 );
2695 }
2696
2697 #[test]
2698 fn test_parse_size_bytes() {
2699 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
2700 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
2701 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
2702 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
2703 assert_eq!(parse_size_bytes("1024B"), Some(1024));
2704 assert_eq!(parse_size_bytes("1024"), Some(1024));
2705
2706 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
2708 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
2709
2710 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
2712 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
2713
2714 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
2716 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
2717
2718 assert_eq!(
2720 parse_size_bytes("1.5GB"),
2721 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
2722 );
2723
2724 assert_eq!(parse_size_bytes(""), None);
2726 assert_eq!(parse_size_bytes("abc"), None);
2727 assert_eq!(parse_size_bytes("50XB"), None);
2728 }
2729
2730 #[test]
2731 fn test_cache_limit_config_from_toml() {
2732 let toml_str = r#"
2733[remote]
2734cache_limit = "50GB"
2735"#;
2736 let cfg: Config = toml::from_str(toml_str).unwrap();
2737 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
2738 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
2739 }
2740
2741 #[test]
2742 fn test_cache_limit_none_by_default() {
2743 let cfg = Config::default();
2744 assert!(cfg.remote.cache_limit.is_none());
2745 assert!(cfg.cache_limit_bytes().is_none());
2746 }
2747
2748 #[test]
2749 fn test_cache_limit_not_serialized_when_none() {
2750 let cfg = Config::default();
2751 let serialized = toml::to_string_pretty(&cfg).unwrap();
2752 assert!(!serialized.contains("cache_limit"));
2753 }
2754
2755 #[test]
2756 fn player_uses_config_on_init() {
2757 let dir = tempfile::tempdir().unwrap();
2761 let path = dir.path().join("config.toml");
2762 fs::write(
2763 &path,
2764 r#"
2765[playback]
2766replaygain = "track"
2767output_device = "My Fancy DAC"
2768pre_amp_db = -3.5
2769target_fps = 30
2770art_size = 32
2771
2772[visualizer]
2773enabled = false
2774mode = "oscilloscope"
2775fps = 30
2776"#,
2777 )
2778 .unwrap();
2779
2780 let cfg = Config::load_from(&path).unwrap();
2781
2782 assert_eq!(
2784 cfg.playback.replaygain,
2785 ReplayGainMode::Track,
2786 "replaygain should be 'track'"
2787 );
2788 assert_eq!(
2789 cfg.playback.output_device.as_deref(),
2790 Some("My Fancy DAC"),
2791 "output_device should match config"
2792 );
2793 assert!(
2794 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
2795 "pre_amp_db should be -3.5"
2796 );
2797 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
2798 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
2799
2800 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
2802 assert_eq!(cfg.visualizer.mode, "oscilloscope");
2803 assert_eq!(cfg.visualizer.fps, 30);
2804 }
2805
2806 #[test]
2807 fn a_missing_config_file_stamps_as_absent() {
2808 let dir = tempfile::tempdir().unwrap();
2809 let base = dir.path().join("config.toml");
2810 let local = dir.path().join("config.local.toml");
2811
2812 assert_eq!(stamp_of(&base, &local), (None, None));
2813
2814 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
2815 let (base_stamp, local_stamp) = stamp_of(&base, &local);
2816 assert!(base_stamp.is_some(), "creating the file must be a change");
2817 assert!(local_stamp.is_none());
2818 }
2819
2820 #[test]
2821 fn editing_a_config_file_changes_its_stamp() {
2822 let dir = tempfile::tempdir().unwrap();
2823 let base = dir.path().join("config.toml");
2824 let local = dir.path().join("config.local.toml");
2825 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
2826
2827 let before = stamp_of(&base, &local);
2828 std::thread::sleep(std::time::Duration::from_millis(20));
2830 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
2831
2832 assert_ne!(
2833 before,
2834 stamp_of(&base, &local),
2835 "a config edited by hand has to be picked up"
2836 );
2837 }
2838
2839 #[test]
2840 fn invalidating_forces_a_reload() {
2841 let first = Config::cached();
2842 Config::invalidate_cache();
2843 assert!(
2844 !Arc::ptr_eq(&first, &Config::cached()),
2845 "koan's own writes invalidate explicitly; the next read must re-parse"
2846 );
2847 }
2848
2849 pub(crate) static PERSIST_LOCK: &std::sync::Mutex<()> = &super::SWITCHING;
2854
2855 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
2857 let dir =
2858 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
2859 let _ = fs::remove_dir_all(&dir);
2860 fs::create_dir_all(&dir).unwrap();
2861 set_config_dir(&dir);
2862 (config_file_path(), config_local_file_path())
2863 }
2864
2865 #[cfg(unix)]
2869 #[test]
2870 fn a_write_keeps_links_modes_and_no_leftovers() {
2871 use std::os::unix::fs::PermissionsExt;
2872 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2873 let dir = tempfile::tempdir().unwrap();
2874 let dotfiles = tempfile::tempdir().unwrap();
2875 std::os::unix::fs::symlink(
2876 dotfiles.path().join("config.toml"),
2877 dir.path().join("config.toml"),
2878 )
2879 .unwrap();
2880 set_config_dir(dir.path());
2881
2882 Config::persist(|c| c.graphql.port = 4100).unwrap();
2883 assert!(
2884 fs::symlink_metadata(config_file_path())
2885 .unwrap()
2886 .is_symlink()
2887 );
2888 assert!(
2889 fs::read_to_string(dotfiles.path().join("config.toml"))
2890 .unwrap()
2891 .contains("4100")
2892 );
2893
2894 fs::set_permissions(
2895 dotfiles.path().join("config.toml"),
2896 fs::Permissions::from_mode(0o640),
2897 )
2898 .unwrap();
2899 Config::persist(|c| {
2900 c.graphql.port = 4101;
2901 c.remote.password = "hunter2".into();
2902 })
2903 .unwrap();
2904 let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
2905 assert_eq!(mode(dotfiles.path().join("config.toml")), 0o640);
2906 assert_eq!(mode(config_local_file_path()), 0o600);
2907 assert!(
2908 fs::symlink_metadata(config_file_path())
2909 .unwrap()
2910 .is_symlink()
2911 );
2912
2913 let names = |d: &Path| -> Vec<String> {
2914 fs::read_dir(d)
2915 .unwrap()
2916 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2917 .collect()
2918 };
2919 assert!(!names(dir.path()).iter().any(|n| n.ends_with(".tmp")));
2920 assert_eq!(names(dotfiles.path()), vec!["config.toml".to_string()]);
2921 }
2922
2923 #[test]
2927 fn a_read_during_switches_is_never_kept_for_the_wrong_directory() {
2928 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2929 let dirs: Vec<tempfile::TempDir> = (0..2).map(|_| tempfile::tempdir().unwrap()).collect();
2930 for (dir, port) in dirs.iter().zip([4001, 4002]) {
2931 fs::write(
2932 dir.path().join("config.toml"),
2933 format!("[graphql]\nport = {port}\n"),
2934 )
2935 .unwrap();
2936 }
2937 let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
2938 let reader = std::thread::spawn({
2939 let done = done.clone();
2940 move || {
2941 while !done.load(std::sync::atomic::Ordering::Relaxed) {
2942 Config::cached();
2943 }
2944 }
2945 });
2946 for i in 0..2000 {
2947 let (dir, port) = if i % 2 == 0 {
2948 (&dirs[0], 4001)
2949 } else {
2950 (&dirs[1], 4002)
2951 };
2952 set_config_dir(dir.path());
2953 for _ in 0..3 {
2954 assert_eq!(Config::cached().graphql.port, port, "switch {i}");
2955 }
2956 }
2957 done.store(true, std::sync::atomic::Ordering::Relaxed);
2958 reader.join().unwrap();
2959 }
2960
2961 #[test]
2962 fn persist_keeps_comments_and_untouched_keys() {
2963 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2964 let (base, _local) = persist_sandbox("comments");
2965 fs::write(
2966 &base,
2967 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
2968 )
2969 .unwrap();
2970
2971 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
2972
2973 let written = fs::read_to_string(&base).unwrap();
2974 assert!(
2975 written.contains("# koan — shareable defaults"),
2976 "the header comment must survive a write: {written}"
2977 );
2978 assert!(
2979 written.contains("# fps = 60"),
2980 "commented-out defaults are the template's whole point: {written}"
2981 );
2982 assert!(written.contains("palette = \"neon\""));
2983 assert!(
2984 !written.contains("[graphql]"),
2985 "an untouched section must not be invented: {written}"
2986 );
2987 }
2988
2989 #[test]
2990 fn persist_routes_machine_settings_to_the_local_file() {
2991 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2992 let (base, local) = persist_sandbox("routing");
2993
2994 Config::persist(|cfg| {
2995 cfg.playback.replaygain = ReplayGainMode::Album;
2996 cfg.playback.output_device = Some("My DAC".into());
2997 cfg.playback.art_size = 40;
2998 cfg.visualizer.mode = "starfield".into();
2999 })
3000 .unwrap();
3001
3002 let shared = fs::read_to_string(&base).unwrap();
3003 let machine = fs::read_to_string(&local).unwrap();
3004
3005 assert!(shared.contains("replaygain = \"album\""), "{shared}");
3006 for machine_only in ["output_device", "art_size", "starfield"] {
3007 assert!(
3008 !shared.contains(machine_only),
3009 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
3010 );
3011 assert!(machine.contains(machine_only), "{machine}");
3012 }
3013 }
3014
3015 #[test]
3016 fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
3017 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3018 let (base, local) = persist_sandbox("dsp");
3019
3020 let profile = DspProfile {
3021 name: "HD 600".into(),
3022 devices: vec!["Topping E30".into()],
3023 preamp_db: None,
3024 filters: vec![
3025 DspFilter::Band(EqFilter {
3026 kind: EqFilterKind::Peaking,
3027 freq: 20.0,
3028 gain_db: -1.3,
3029 q: 2.0,
3030 channels: vec![],
3031 }),
3032 DspFilter::Band(EqFilter {
3033 kind: EqFilterKind::HighShelfFirstOrder,
3034 freq: 10000.0,
3035 gain_db: 2.5,
3036 q: 0.7,
3037 channels: vec![],
3038 }),
3039 DspFilter::Delay(Delay {
3040 ms: 1.5,
3041 channels: vec![1],
3042 ..Default::default()
3043 }),
3044 DspFilter::Mix(Mix {
3045 outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
3046 }),
3047 DspFilter::Graphic(GraphicEq {
3048 points: vec![(20.0, -1.0), (1000.0, 0.0)],
3049 channels: vec![],
3050 }),
3051 ],
3052 impulses: vec![],
3053 source: vec![],
3054 target: None,
3055 layers: vec![],
3056 group: false,
3057 preset: false,
3058 filters_only: false,
3059 scope: None,
3060 uid: None,
3061 origin: None,
3062 role: None,
3063 measurement: None,
3064 fitted: None,
3065 tuned_for: None,
3066 original: None,
3067 };
3068 Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
3069
3070 assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
3071 let machine = fs::read_to_string(&local).unwrap();
3072 assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
3073 assert!(
3074 machine.contains("\n { type = \"peaking\""),
3075 "one band per line: {machine}"
3076 );
3077 assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
3078 assert_eq!(
3079 Config::from_files()
3080 .unwrap()
3081 .dsp
3082 .profile_for("Topping E30")
3083 .map(|p| p.name.as_str()),
3084 Some("HD 600")
3085 );
3086 }
3087
3088 #[test]
3091 fn a_profile_is_played_within_bounds() {
3092 let band = |freq: f64, gain_db: f64, q: f64| {
3093 DspFilter::Band(EqFilter {
3094 kind: EqFilterKind::Peaking,
3095 freq,
3096 gain_db,
3097 q,
3098 channels: vec![],
3099 })
3100 };
3101 let mut p = DspProfile {
3102 name: "Loud\u{7}".into(),
3103 preamp_db: Some(60.0),
3104 filters: vec![
3105 DspFilter::Delay(Delay {
3106 ms: 1e12,
3107 ..Default::default()
3108 }),
3109 band(0.5, 200.0, 0.0),
3110 band(f64::NAN, 0.0, 1.0),
3111 DspFilter::Band(EqFilter {
3112 kind: EqFilterKind::Peaking,
3113 freq: 1000.0,
3114 gain_db: 0.0,
3115 q: 1.0,
3116 channels: vec![99],
3117 }),
3118 DspFilter::Mix(Mix {
3119 outputs: vec![vec![(0, 1e9), (1, f64::INFINITY)]],
3120 }),
3121 DspFilter::Graphic(GraphicEq {
3122 points: (0..3000).map(|i| (i as f64, 1.0)).collect(),
3123 channels: vec![],
3124 }),
3125 ],
3126 ..Default::default()
3127 };
3128 p.filters.extend((0..300).map(|_| band(1000.0, 1.0, 1.0)));
3129 let notes = p.sanitize();
3130 let has = |n: &str| notes.iter().any(|x| x == n);
3131 assert!(has("delay clamped to 2000 ms"), "{notes:?}");
3132 assert!(has("preamp clamped to 30 dB"), "{notes:?}");
3133 assert!(has("a band's gain clamped to 30 dB"), "{notes:?}");
3134 assert!(has("a band's frequency clamped to 1 Hz"), "{notes:?}");
3135 assert!(has("a filter that could not play dropped"), "{notes:?}");
3136 assert!(has("bands past 64 a channel dropped"), "{notes:?}");
3137 assert!(has("graphic EQ points past 2048 dropped"), "{notes:?}");
3138 assert_eq!(p.name, "Loud");
3139 assert_eq!(p.preamp_db, Some(30.0));
3140 assert_eq!(
3141 p.filters[0],
3142 DspFilter::Delay(Delay {
3143 ms: 2000.0,
3144 ..Default::default()
3145 })
3146 );
3147 assert_eq!(p.filters[1], band(1.0, 30.0, 0.01));
3148 let DspFilter::Mix(m) = &p.filters[2] else {
3149 panic!("{:?}", p.filters[2])
3150 };
3151 assert_eq!(m.outputs, vec![vec![(0, dsp_bounds::MIX_GAIN)]]);
3152 let bands = p
3153 .filters
3154 .iter()
3155 .filter(|f| matches!(f, DspFilter::Band(_)))
3156 .count();
3157 assert_eq!(bands, dsp_bounds::BANDS_PER_CHANNEL);
3158 assert!(p.clone().sanitize().is_empty(), "within bounds now");
3159 }
3160
3161 #[test]
3162 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
3163 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3164 let (base, _local) = persist_sandbox("folders");
3165
3166 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
3169
3170 let shared = fs::read_to_string(&base).unwrap_or_default();
3171 assert!(
3172 !shared.contains("folders"),
3173 "a visualiser toggle must not invent library folders: {shared}"
3174 );
3175 }
3176
3177 #[test]
3178 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
3179 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3180 let (base, local) = persist_sandbox("drain");
3181 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
3182
3183 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
3184
3185 let shared = fs::read_to_string(&base).unwrap();
3186 assert!(
3187 !shared.contains("art_size"),
3188 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
3189 );
3190 assert!(shared.contains("target_fps"), "{shared}");
3191 assert!(
3192 fs::read_to_string(&local)
3193 .unwrap()
3194 .contains("art_size = 48")
3195 );
3196 }
3197
3198 #[test]
3199 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
3200 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3201 let (_base, local) = persist_sandbox("shadow");
3202 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
3203
3204 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
3205
3206 assert_eq!(
3207 Config::from_files().unwrap().playback.target_fps,
3208 120,
3209 "local wins the merge, so a shared write over a local copy would \
3210 otherwise be silently ignored: {}",
3211 fs::read_to_string(&local).unwrap()
3212 );
3213 }
3214
3215 #[test]
3216 fn lastfm_credentials_are_secrets_and_need_both_halves() {
3217 assert!(matches!(layer_of("lastfm.api_key"), Layer::Machine));
3218 assert!(matches!(layer_of("lastfm.secret"), Layer::Machine));
3219 let mut cfg = LastfmConfig::default();
3220 assert!(!cfg.is_set());
3221 cfg.api_key = "key".into();
3222 assert!(!cfg.is_set());
3223 cfg.secret = " ".into();
3224 assert!(!cfg.is_set());
3225 cfg.secret = "secret".into();
3226 assert!(cfg.is_set());
3227 }
3228
3229 #[test]
3230 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
3231 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3232 let (base, _local) = persist_sandbox("noop");
3233 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
3234
3235 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
3236
3237 assert_eq!(
3238 fs::read_to_string(&base).unwrap(),
3239 "# untouched\n[playback]\ntarget_fps = 60\n"
3240 );
3241 }
3242
3243 #[test]
3244 fn persist_keeps_passwords_out_of_the_shared_file() {
3245 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3246 let (base, local) = persist_sandbox("secrets");
3247
3248 Config::persist(|cfg| {
3249 cfg.remote.password = "hunter2".into();
3250 cfg.subsonic.password = "s3cret".into();
3251 cfg.visualizer.palette = "mono".into();
3252 })
3253 .unwrap();
3254
3255 let shared = fs::read_to_string(&base).unwrap();
3256 assert!(!shared.contains("hunter2"), "{shared}");
3257 assert!(!shared.contains("s3cret"), "{shared}");
3258 assert!(shared.contains("mono"));
3259
3260 let machine = fs::read_to_string(&local).unwrap();
3261 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
3262 }
3263
3264 #[test]
3265 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
3266 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3267 let (_base, local) = persist_sandbox("clear-secret");
3268 fs::write(
3269 &local,
3270 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
3271 )
3272 .unwrap();
3273
3274 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
3275
3276 let machine = fs::read_to_string(&local).unwrap();
3277 assert!(
3278 !machine.contains("password"),
3279 "an emptied secret should leave no key behind: {machine}"
3280 );
3281 assert!(machine.contains("url"), "{machine}");
3282 }
3283
3284 #[test]
3285 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
3286 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3287 let (base, _local) = persist_sandbox("patterns");
3288 fs::write(
3289 &base,
3290 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
3291 )
3292 .unwrap();
3293
3294 Config::persist(|cfg| {
3295 cfg.organize
3296 .patterns
3297 .insert("standard".into(), "%album artist%/%album%".into());
3298 })
3299 .unwrap();
3300
3301 let cfg = Config::from_files().unwrap();
3302 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
3303 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
3304 }
3305}