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 devices: DevicesConfig,
40 pub dsp: DspConfig,
41 pub appearance: AppearanceConfig,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct AppearanceConfig {
48 pub theme: String,
51 pub theme_icons: bool,
54 pub record_colours: bool,
58 pub wash_window: bool,
63 pub rainbow: bool,
66 pub text_size: u16,
69}
70
71impl Default for AppearanceConfig {
72 fn default() -> Self {
73 Self {
74 theme: "koan".into(),
75 theme_icons: true,
76 record_colours: true,
77 wash_window: true,
78 rainbow: false,
79 text_size: 100,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86#[serde(default)]
87pub struct SharingConfig {
88 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub public_url: Option<String>,
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[serde(default)]
98pub struct McpConfig {
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub redirect_hosts: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct PushConfig {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub key_path: Option<PathBuf>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub key: Option<String>,
120 #[serde(skip_serializing_if = "String::is_empty")]
122 pub key_id: String,
123 #[serde(skip_serializing_if = "String::is_empty")]
125 pub team_id: String,
126 pub topic: String,
128}
129
130impl Default for PushConfig {
131 fn default() -> Self {
132 Self {
133 key_path: None,
134 key: None,
135 key_id: String::new(),
136 team_id: String::new(),
137 topic: "cc.blit.koan".into(),
138 }
139 }
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146#[serde(default)]
147pub struct LibraryConfig {
148 pub folders: Vec<PathBuf>,
149}
150
151pub fn former_default_folder() -> PathBuf {
155 dirs::audio_dir().unwrap_or_else(|| {
156 dirs::home_dir()
157 .map(|h| h.join("Music"))
158 .unwrap_or_else(|| PathBuf::from("/Music"))
159 })
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(default)]
164pub struct PlaybackConfig {
165 pub replaygain: ReplayGainMode,
166 pub target_fps: u8,
169 pub show_fps: bool,
171 pub pre_amp_db: f64,
174 pub fade_on_pause: bool,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub output_device: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub renderer: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub renderer_name: Option<String>,
187 pub renderers: bool,
190 pub muted: bool,
193 pub art_size: u16,
196 pub rate_switch_lead_in_ms: u32,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum ReplayGainMode {
205 Off,
206 Track,
207 Album,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(default)]
212pub struct RemoteConfig {
213 pub enabled: bool,
214 pub url: String,
215 pub username: String,
216 #[serde(default, skip_serializing_if = "String::is_empty")]
218 pub password: String,
219 #[serde(default, skip_serializing_if = "String::is_empty")]
222 pub api_key: String,
223 #[serde(default, skip_serializing_if = "String::is_empty")]
229 pub device_key: String,
230 pub cache_dir: Option<PathBuf>,
232 pub download_workers: usize,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub cache_limit: Option<String>,
239 pub auto_sync: bool,
245 pub auto_sync_interval_mins: u64,
247 pub play_queue: bool,
253}
254
255impl Default for PlaybackConfig {
256 fn default() -> Self {
257 Self {
258 replaygain: ReplayGainMode::Off,
259 target_fps: 60,
260 show_fps: false,
261 pre_amp_db: 0.0,
262 fade_on_pause: true,
263 output_device: None,
264 renderer: None,
265 renderer_name: None,
266 renderers: true,
267 muted: false,
268 art_size: 24,
269 rate_switch_lead_in_ms: 1000,
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(default)]
276pub struct VisualizerConfig {
277 pub enabled: bool,
278 pub fps: u8,
279 pub mode: String,
282 pub scale: String,
284 pub amplitude_scale: String,
286 pub bar_decay_ms: u32,
288 pub peak_decay_ms: u32,
290 pub palette: String,
293 pub reactivity: f32,
297 pub bass_shake: bool,
300 pub matrix_overlay: bool,
303 pub reactive_bg: bool,
305}
306
307impl Default for VisualizerConfig {
308 fn default() -> Self {
309 Self {
310 enabled: true,
311 fps: 60,
312 mode: "bars".into(),
313 scale: "bark".into(),
314 amplitude_scale: "aweight".into(),
315 bar_decay_ms: 50,
316 peak_decay_ms: 180,
317 palette: "spectrum".into(),
318 reactivity: 1.0,
319 bass_shake: true,
320 matrix_overlay: false,
321 reactive_bg: false,
322 }
323 }
324}
325
326impl Default for RemoteConfig {
327 fn default() -> Self {
328 Self {
329 enabled: false,
330 url: String::new(),
331 username: String::new(),
332 password: String::new(),
333 api_key: String::new(),
334 device_key: String::new(),
335 cache_dir: None,
336 download_workers: 5,
337 cache_limit: None,
338 auto_sync: true,
339 auto_sync_interval_mins: 60,
340 play_queue: false,
341 }
342 }
343}
344
345pub fn parse_size_bytes(s: &str) -> Option<u64> {
348 let s = s.trim();
349 if s.is_empty() {
350 return None;
351 }
352
353 let mut num_end = 0;
355 for (i, c) in s.char_indices() {
356 if c.is_ascii_digit() || c == '.' {
357 num_end = i + c.len_utf8();
358 } else if !c.is_whitespace() {
359 break;
360 }
361 }
362
363 let num_str = s[..num_end].trim();
364 let suffix = s[num_end..].trim().to_ascii_uppercase();
365
366 let value: f64 = num_str.parse().ok()?;
367 let multiplier: u64 = match suffix.as_str() {
368 "" | "B" => 1,
369 "KB" | "K" => 1024,
370 "MB" | "M" => 1024 * 1024,
371 "GB" | "G" => 1024 * 1024 * 1024,
372 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
373 _ => return None,
374 };
375
376 Some((value * multiplier as f64) as u64)
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380#[serde(default)]
381pub struct OrganizeConfig {
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub default: Option<String>,
385 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
387 pub patterns: HashMap<String, String>,
388 #[serde(default = "default_true")]
392 pub move_ancillary: bool,
393}
394
395impl Default for OrganizeConfig {
396 fn default() -> Self {
397 Self {
398 default: None,
399 patterns: HashMap::new(),
400 move_ancillary: true,
401 }
402 }
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(default)]
408pub struct GraphqlConfig {
409 pub enabled: bool,
412 pub port: u16,
414 #[serde(default = "default_bind")]
417 pub bind: std::net::IpAddr,
418 pub playground: bool,
420 pub auth_enabled: bool,
423 pub access_token_ttl: String,
425 pub refresh_token_ttl: String,
427 pub cors_origins: Vec<String>,
430 pub allowed_hosts: Vec<String>,
434 pub cookie_secure: bool,
438 #[serde(default, skip_serializing_if = "String::is_empty")]
443 pub proxy_auth_header: String,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 pub proxy_auth_from: Vec<String>,
448 pub allow_organize: bool,
450}
451
452fn default_true() -> bool {
453 true
454}
455
456fn default_bind() -> std::net::IpAddr {
457 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
458}
459
460impl Default for GraphqlConfig {
461 fn default() -> Self {
462 Self {
463 enabled: true,
464 port: 4000,
465 bind: default_bind(),
466 playground: false,
467 auth_enabled: true,
468 access_token_ttl: "15m".into(),
469 refresh_token_ttl: "30d".into(),
470 cors_origins: Vec::new(),
471 allowed_hosts: Vec::new(),
472 cookie_secure: false,
473 proxy_auth_header: String::new(),
474 proxy_auth_from: Vec::new(),
475 allow_organize: false,
476 }
477 }
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(default)]
489pub struct SubsonicConfig {
490 pub enabled: bool,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub port: Option<u16>,
498 pub username: String,
500 #[serde(default, skip_serializing_if = "String::is_empty")]
503 pub password: String,
504 pub transcode: bool,
507 pub ffmpeg: String,
510}
511
512impl Default for SubsonicConfig {
513 fn default() -> Self {
514 Self {
515 enabled: false,
516 port: None,
517 username: "koan".into(),
518 password: String::new(),
519 transcode: true,
520 ffmpeg: "ffmpeg".into(),
521 }
522 }
523}
524
525#[derive(Debug, Clone, Default, Serialize, Deserialize)]
530#[serde(default)]
531pub struct AuthConfig {
532 #[serde(skip_serializing_if = "String::is_empty")]
534 pub server: String,
535 #[serde(skip_serializing_if = "String::is_empty")]
538 pub refresh_token: String,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
545#[serde(default)]
546pub struct DevicesConfig {
547 pub nearby: bool,
553 pub discoverable: bool,
556 pub port: u16,
559 pub addresses: Vec<String>,
562 pub nearby_control: NearbyControl,
565 pub refused: Vec<String>,
568 pub keep_running: bool,
572}
573
574#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum NearbyControl {
578 #[default]
581 Full,
582 Playback,
585}
586
587impl Default for DevicesConfig {
588 fn default() -> Self {
589 Self {
590 nearby: true,
591 discoverable: true,
592 port: DEVICES_PORT,
593 addresses: Vec::new(),
594 nearby_control: NearbyControl::Full,
595 refused: Vec::new(),
596 keep_running: false,
597 }
598 }
599}
600
601pub const DEVICES_PORT: u16 = 5626;
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
609#[serde(default)]
610pub struct DspConfig {
611 pub enabled: bool,
613 pub profiles: Vec<DspProfile>,
614 #[serde(skip_serializing_if = "Vec::is_empty")]
617 pub autoeq_dismissed: Vec<String>,
618 #[serde(skip_serializing_if = "Vec::is_empty")]
622 pub tunings: Vec<DspOutputTuning>,
623 #[serde(skip_serializing_if = "Vec::is_empty")]
626 pub presets: Vec<DspOutputPreset>,
627 #[serde(skip_serializing_if = "Vec::is_empty")]
630 pub filters_only: Vec<String>,
631 #[serde(skip_serializing_if = "Vec::is_empty")]
636 pub removed: Vec<String>,
637}
638
639#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
641pub struct DspOutputTuning {
642 pub device: String,
643 pub tuning: String,
644 #[serde(default = "on", skip_serializing_if = "is_on")]
646 pub on: bool,
647}
648
649fn on() -> bool {
650 true
651}
652
653fn is_on(on: &bool) -> bool {
654 *on
655}
656
657#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
659pub struct DspOutputPreset {
660 pub device: String,
661 pub preset: String,
662}
663
664impl Default for DspConfig {
665 fn default() -> Self {
666 Self {
667 enabled: true,
668 profiles: Vec::new(),
669 autoeq_dismissed: Vec::new(),
670 tunings: Vec::new(),
671 presets: Vec::new(),
672 filters_only: Vec::new(),
673 removed: Vec::new(),
674 }
675 }
676}
677
678impl DspProfile {
679 pub fn measured(&self) -> Option<&DspMeasurement> {
682 self.fitted.as_ref().or(self.measurement.as_ref())
683 }
684}
685
686impl DspConfig {
687 pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
691 if !self.enabled {
692 return None;
693 }
694 let only = self.filters_only.iter().any(|d| d == device);
695 self.profiles
696 .iter()
697 .find(|p| p.devices.iter().any(|d| d == device))
698 .filter(|p| {
699 !only || !crate::audio::dsp::profiles::shown_role(p, &self.profiles).corrects()
700 })
701 }
702}
703
704#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
705#[serde(default)]
706pub struct DspProfile {
707 pub name: String,
708 pub devices: Vec<String>,
710 #[serde(skip_serializing_if = "Option::is_none")]
713 pub preamp_db: Option<f64>,
714 pub filters: Vec<DspFilter>,
716 pub impulses: Vec<PathBuf>,
720 #[serde(default, skip_serializing_if = "Vec::is_empty")]
723 pub source: Vec<String>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub target: Option<DspTarget>,
728 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub layers: Vec<DspLayer>,
733 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
738 pub group: bool,
739 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
743 pub preset: bool,
744 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
747 pub filters_only: bool,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub scope: Option<DspScope>,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub uid: Option<String>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub origin: Option<String>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub role: Option<DspRole>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub measurement: Option<DspMeasurement>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub fitted: Option<DspMeasurement>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub tuned_for: Option<String>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub original: Option<DspOriginal>,
788}
789
790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
792pub struct DspOriginal {
793 pub filters: Vec<DspFilter>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub preamp_db: Option<f64>,
796}
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(rename_all = "snake_case")]
802pub enum DspRole {
803 Correction,
806 Tuning,
808 Baked,
813}
814
815impl DspRole {
816 pub fn corrects(self) -> bool {
818 matches!(self, Self::Correction | Self::Baked)
819 }
820}
821
822#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
824pub struct DspMeasurement {
825 pub ear: DspEar,
826 pub target: String,
828}
829
830#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
831#[serde(rename_all = "snake_case")]
832pub enum DspEar {
833 In,
834 Over,
835 Speaker,
836}
837
838pub mod dsp_bounds {
843 use std::ops::RangeInclusive;
844 pub const FREQ_HZ: RangeInclusive<f64> = 1.0..=48_000.0;
845 pub const GAIN_DB: RangeInclusive<f64> = -30.0..=30.0;
846 pub const Q: RangeInclusive<f64> = 0.01..=100.0;
847 pub const DELAY_MS: RangeInclusive<f64> = 0.0..=2_000.0;
848 pub const DELAY_SAMPLES: RangeInclusive<f64> = 0.0..=768_000.0;
850 pub const MIX_GAIN: f64 = 31.63;
852 pub const CHANNELS: u16 = 64;
853 pub const BANDS_PER_CHANNEL: usize = 64;
855 pub const FILTERS: usize = 256;
856 pub const GRAPHIC_POINTS: usize = 2_048;
857 pub const LAYERS: usize = 32;
858 pub const NAME: usize = 128;
859
860 pub const CHAIN_DELAY_MS: f64 = 2_000.0;
868 pub const CHAIN_GRAPHICS: usize = 2;
872 pub const CHAIN_MIXES: usize = 8;
874 pub const IMPULSE_TAPS: usize = 262_145;
878}
879
880pub fn budget(filters: &mut Vec<DspFilter>) -> Vec<String> {
884 use dsp_bounds as b;
885 let mut notes = Vec::new();
886 let mut note = |n: String| {
887 if !notes.contains(&n) {
888 notes.push(n);
889 }
890 };
891 let channels = b::CHANNELS as usize;
892 let mut delay = vec![0.0f64; channels];
893 let mut graphics = vec![0usize; channels];
894 let mut mixes = 0;
895 let on = |cs: &[u16], c: usize| cs.is_empty() || cs.contains(&(c as u16));
896 filters.retain_mut(|f| match f {
897 DspFilter::Delay(d) => {
898 let ms = d.ms + d.samples / 44.1;
899 let room = (0..channels)
900 .filter(|c| on(&d.channels, *c))
901 .map(|c| b::CHAIN_DELAY_MS - delay[c])
902 .fold(b::CHAIN_DELAY_MS, f64::min)
903 .max(0.0);
904 if ms > room {
905 note(format!(
906 "delays shortened to {} ms in all",
907 b::CHAIN_DELAY_MS
908 ));
909 if room <= 0.0 {
910 return false;
911 }
912 let scale = room / ms;
914 d.ms *= scale;
915 d.samples = (d.samples * scale).floor();
916 }
917 let ms = d.ms + d.samples / 44.1;
918 for (c, total) in delay.iter_mut().enumerate() {
919 if on(&d.channels, c) {
920 *total += ms;
921 }
922 }
923 true
924 }
925 DspFilter::Graphic(g) => {
926 let full =
927 (0..channels).any(|c| on(&g.channels, c) && graphics[c] == b::CHAIN_GRAPHICS);
928 if full {
929 note(format!(
930 "graphic EQs past {} a channel dropped",
931 b::CHAIN_GRAPHICS
932 ));
933 return false;
934 }
935 for (c, n) in graphics.iter_mut().enumerate() {
936 if on(&g.channels, c) {
937 *n += 1;
938 }
939 }
940 true
941 }
942 DspFilter::Mix(_) => {
943 mixes += 1;
944 if mixes > b::CHAIN_MIXES {
945 note(format!("mixes past {} dropped", b::CHAIN_MIXES));
946 return false;
947 }
948 true
949 }
950 DspFilter::Band(_) => true,
951 });
952 notes
953}
954
955impl DspProfile {
956 pub fn sanitize(&mut self) -> Vec<String> {
964 use dsp_bounds as b;
965 let mut notes: Vec<String> = Vec::new();
966 let mut note = |n: String| {
967 if !notes.contains(&n) {
968 notes.push(n);
969 }
970 };
971 let mut clamp = |what: &str, v: &mut f64, r: &std::ops::RangeInclusive<f64>, unit: &str| {
972 let c = v.clamp(*r.start(), *r.end());
973 if c != *v {
974 note(format!(
975 "{what} clamped to {}{unit}",
976 if *v > *r.end() { r.end() } else { r.start() }
977 ));
978 *v = c;
979 }
980 };
981 let finite = |v: f64| v.is_finite();
982 let mut dropped = Vec::new();
983
984 let cleaned: String = self
985 .name
986 .chars()
987 .filter(|c| !c.is_control())
988 .take(b::NAME)
989 .collect();
990 let cleaned = if cleaned.trim().is_empty() {
991 "Profile".to_owned()
992 } else {
993 cleaned
994 };
995 if cleaned != self.name {
996 dropped.push("name shortened".to_owned());
997 self.name = cleaned;
998 }
999 if let Some(db) = self.preamp_db.as_mut() {
1000 if finite(*db) {
1001 clamp("preamp", db, &b::GAIN_DB, " dB");
1002 } else {
1003 self.preamp_db = None;
1004 dropped.push("preamp dropped".to_owned());
1005 }
1006 }
1007 let bad_channel = |cs: &[u16]| cs.iter().any(|c| *c >= b::CHANNELS);
1008 let mut per_channel = [0usize; b::CHANNELS as usize];
1009 let before = self.filters.len();
1010 let mut kept = Vec::with_capacity(before.min(b::FILTERS));
1011 for mut filter in std::mem::take(&mut self.filters) {
1012 if kept.len() == b::FILTERS {
1013 dropped.push(format!("filters past {} dropped", b::FILTERS));
1014 break;
1015 }
1016 let keep = match &mut filter {
1017 DspFilter::Band(f) => {
1018 if ![f.freq, f.gain_db, f.q].into_iter().all(finite) || bad_channel(&f.channels)
1019 {
1020 false
1021 } else {
1022 let on =
1023 |c: usize| f.channels.is_empty() || f.channels.contains(&(c as u16));
1024 if (0..b::CHANNELS as usize)
1025 .any(|c| on(c) && per_channel[c] == b::BANDS_PER_CHANNEL)
1026 {
1027 dropped.push(format!(
1028 "bands past {} a channel dropped",
1029 b::BANDS_PER_CHANNEL
1030 ));
1031 continue;
1032 } else {
1033 for (c, n) in per_channel.iter_mut().enumerate() {
1034 if on(c) {
1035 *n += 1;
1036 }
1037 }
1038 clamp("a band's frequency", &mut f.freq, &b::FREQ_HZ, " Hz");
1039 clamp("a band's gain", &mut f.gain_db, &b::GAIN_DB, " dB");
1040 clamp("a band's Q", &mut f.q, &b::Q, "");
1041 true
1042 }
1043 }
1044 }
1045 DspFilter::Delay(d) => {
1046 if !finite(d.ms) || !finite(d.samples) || bad_channel(&d.channels) {
1047 false
1048 } else {
1049 clamp("delay", &mut d.ms, &b::DELAY_MS, " ms");
1050 clamp("delay", &mut d.samples, &b::DELAY_SAMPLES, " samples");
1051 true
1052 }
1053 }
1054 DspFilter::Mix(m) => {
1055 m.outputs.truncate(b::CHANNELS as usize);
1056 for o in &mut m.outputs {
1057 o.retain(|(c, g)| *c < b::CHANNELS && finite(*g));
1058 o.truncate(b::CHANNELS as usize);
1059 for (_, g) in o.iter_mut() {
1060 clamp("a mix's gain", g, &(-b::MIX_GAIN..=b::MIX_GAIN), "");
1061 }
1062 }
1063 true
1064 }
1065 DspFilter::Graphic(g) => {
1066 if bad_channel(&g.channels) {
1067 false
1068 } else {
1069 g.points.retain(|(hz, db)| finite(*hz) && finite(*db));
1070 if g.points.len() > b::GRAPHIC_POINTS {
1071 g.points.truncate(b::GRAPHIC_POINTS);
1072 dropped.push(format!(
1073 "graphic EQ points past {} dropped",
1074 b::GRAPHIC_POINTS
1075 ));
1076 }
1077 for (hz, db) in &mut g.points {
1078 clamp(
1079 "a graphic EQ's frequency",
1080 hz,
1081 &(0.0..=*b::FREQ_HZ.end()),
1082 " Hz",
1083 );
1084 clamp("a graphic EQ's gain", db, &b::GAIN_DB, " dB");
1085 }
1086 true
1087 }
1088 }
1089 };
1090 if keep {
1091 kept.push(filter);
1092 } else {
1093 dropped.push("a filter that could not play dropped".to_owned());
1094 }
1095 }
1096 self.filters = kept;
1097 for n in budget(&mut self.filters) {
1098 dropped.push(n);
1099 }
1100 if self.layers.len() > b::LAYERS {
1101 self.layers.truncate(b::LAYERS);
1102 dropped.push(format!("layers past {} dropped", b::LAYERS));
1103 }
1104 self.layers.retain(|l| l.profile.chars().count() <= b::NAME);
1105 if self
1106 .origin
1107 .as_ref()
1108 .is_some_and(|o| o.chars().count() > b::NAME)
1109 {
1110 self.origin = None;
1111 }
1112 if self
1113 .tuned_for
1114 .as_ref()
1115 .is_some_and(|t| t.chars().count() > b::NAME)
1116 {
1117 self.tuned_for = None;
1118 dropped.push("tuned-for target dropped".to_owned());
1119 }
1120 if self
1121 .measurement
1122 .as_ref()
1123 .is_some_and(|m| m.target.chars().count() > b::NAME)
1124 {
1125 self.measurement = None;
1126 dropped.push("measurement's target dropped".to_owned());
1127 }
1128 if self
1129 .fitted
1130 .as_ref()
1131 .is_some_and(|m| m.target.chars().count() > b::NAME)
1132 {
1133 self.fitted = None;
1134 dropped.push("measurement's target dropped".to_owned());
1135 }
1136 if let Some(t) = &mut self.target {
1137 let long = |s: &str| s.chars().count() > b::NAME;
1138 if long(&t.made_for) {
1139 self.target = None;
1140 dropped.push("target dropped".to_owned());
1141 } else if t.chosen.as_deref().is_some_and(long) {
1142 t.chosen = None;
1143 dropped.push("chosen target dropped".to_owned());
1144 }
1145 }
1146 if let Some(o) = self.original.take() {
1148 let mut kept = DspProfile {
1149 filters: o.filters,
1150 preamp_db: o.preamp_db,
1151 ..Default::default()
1152 };
1153 kept.sanitize();
1154 self.original = Some(DspOriginal {
1155 filters: kept.filters,
1156 preamp_db: kept.preamp_db,
1157 });
1158 }
1159 for d in dropped {
1160 note(d);
1161 }
1162 notes
1163 }
1164
1165 pub fn sanitized(&self) -> Self {
1167 let mut p = self.clone();
1168 p.sanitize();
1169 p
1170 }
1171}
1172
1173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1175#[serde(rename_all = "snake_case")]
1176pub enum DspScope {
1177 Everywhere,
1179 Device,
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1185pub struct DspLayer {
1186 pub profile: String,
1187 #[serde(default = "layer_on")]
1188 pub on: bool,
1189}
1190
1191fn layer_on() -> bool {
1192 true
1193}
1194
1195#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1197pub struct DspTarget {
1198 pub made_for: String,
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub chosen: Option<String>,
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1209#[serde(tag = "type", rename_all = "snake_case")]
1210pub enum DspFilter {
1211 Delay(Delay),
1212 Mix(Mix),
1213 Graphic(GraphicEq),
1214 #[serde(untagged)]
1215 Band(EqFilter),
1216}
1217
1218impl DspFilter {
1219 pub fn channels(&self) -> &[u16] {
1222 match self {
1223 Self::Band(f) => &f.channels,
1224 Self::Delay(d) => &d.channels,
1225 Self::Graphic(g) => &g.channels,
1226 Self::Mix(_) => &[],
1227 }
1228 }
1229
1230 pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
1231 match self {
1232 Self::Band(f) => Some(&mut f.channels),
1233 Self::Delay(d) => Some(&mut d.channels),
1234 Self::Graphic(g) => Some(&mut g.channels),
1235 Self::Mix(_) => None,
1236 }
1237 }
1238}
1239
1240impl From<EqFilter> for DspFilter {
1241 fn from(f: EqFilter) -> Self {
1242 Self::Band(f)
1243 }
1244}
1245
1246#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1248#[serde(default)]
1249pub struct Delay {
1250 #[serde(skip_serializing_if = "is_zero")]
1251 pub ms: f64,
1252 #[serde(skip_serializing_if = "is_zero")]
1253 pub samples: f64,
1254 #[serde(skip_serializing_if = "std::ops::Not::not")]
1257 pub subsample: bool,
1258 #[serde(skip_serializing_if = "Vec::is_empty")]
1259 pub channels: Vec<u16>,
1260}
1261
1262fn is_zero(v: &f64) -> bool {
1263 *v == 0.0
1264}
1265
1266#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1270pub struct Mix {
1271 pub outputs: Vec<Vec<(u16, f64)>>,
1272}
1273
1274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1278pub struct GraphicEq {
1279 pub points: Vec<(f64, f64)>,
1280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1281 pub channels: Vec<u16>,
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286pub struct EqFilter {
1287 #[serde(rename = "type")]
1288 pub kind: EqFilterKind,
1289 pub freq: f64,
1291 #[serde(default)]
1292 pub gain_db: f64,
1293 #[serde(default = "default_q")]
1294 pub q: f64,
1295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1297 pub channels: Vec<u16>,
1298}
1299
1300fn default_q() -> f64 {
1301 std::f64::consts::FRAC_1_SQRT_2
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1305#[serde(rename_all = "snake_case")]
1306pub enum EqFilterKind {
1307 Peaking,
1308 LowShelf,
1309 HighShelf,
1310 LowPass,
1311 HighPass,
1312 Notch,
1313 BandPass,
1314 AllPass,
1315 LowShelfFirstOrder,
1318 HighShelfFirstOrder,
1319 LowPassFirstOrder,
1320 HighPassFirstOrder,
1321 AllPassFirstOrder,
1322 Gain,
1324}
1325
1326impl EqFilterKind {
1327 pub fn name(self) -> &'static str {
1329 match self {
1330 Self::Peaking => "peaking",
1331 Self::LowShelf => "low_shelf",
1332 Self::HighShelf => "high_shelf",
1333 Self::LowPass => "low_pass",
1334 Self::HighPass => "high_pass",
1335 Self::Notch => "notch",
1336 Self::BandPass => "band_pass",
1337 Self::AllPass => "all_pass",
1338 Self::LowShelfFirstOrder => "low_shelf_first_order",
1339 Self::HighShelfFirstOrder => "high_shelf_first_order",
1340 Self::LowPassFirstOrder => "low_pass_first_order",
1341 Self::HighPassFirstOrder => "high_pass_first_order",
1342 Self::AllPassFirstOrder => "all_pass_first_order",
1343 Self::Gain => "gain",
1344 }
1345 }
1346}
1347
1348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350pub enum Layer {
1351 Shared,
1353 Machine,
1355}
1356
1357pub fn layer_of(path: &str) -> Layer {
1367 match path {
1368 "remote.password"
1370 | "remote.api_key"
1371 | "remote.device_key"
1372 | "subsonic.password"
1373 | "auth.refresh_token"
1374 | "push.key"
1375 | "push.key_path"
1376 | "push.key_id"
1377 | "push.team_id"
1378 | "library.folders"
1380 | "remote.enabled"
1381 | "remote.url"
1382 | "remote.username"
1383 | "remote.cache_dir"
1384 | "remote.cache_limit"
1385 | "remote.play_queue"
1387 | "playback.output_device"
1389 | "playback.renderer"
1390 | "playback.renderer_name"
1391 | "playback.renderers"
1392 | "playback.muted"
1393 | "playback.rate_switch_lead_in_ms"
1394 | "subsonic.enabled"
1397 | "subsonic.port"
1398 | "subsonic.username"
1399 | "subsonic.transcode"
1400 | "subsonic.ffmpeg"
1401 | "devices.nearby"
1403 | "devices.discoverable"
1404 | "devices.port"
1405 | "devices.addresses"
1406 | "devices.nearby_control"
1407 | "devices.refused"
1408 | "devices.keep_running"
1409 | "auth.server"
1411 | "playback.art_size"
1413 | "visualizer.enabled"
1414 | "visualizer.mode"
1415 | "visualizer.matrix_overlay"
1416 | "visualizer.bass_shake"
1417 | "appearance.rainbow"
1418 | "appearance.text_size" => Layer::Machine,
1420 p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
1422 _ => Layer::Shared,
1423 }
1424}
1425
1426type ConfigStamp = (PathBuf, Option<SystemTime>, Option<SystemTime>);
1433
1434type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
1435
1436static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
1437 LazyLock::new(|| parking_lot::RwLock::new(None));
1438
1439static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1444
1445fn config_stamp() -> ConfigStamp {
1446 let (base, local) = stamp_of(&config_file_path(), &config_local_file_path());
1447 (config_dir(), base, local)
1448}
1449
1450fn stamp_of(base: &Path, local: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
1452 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
1453 (mtime(base), mtime(local))
1454}
1455
1456impl Config {
1457 fn figment() -> Figment {
1463 let base_path = config_file_path();
1464 let local_path = config_local_file_path();
1465
1466 Figment::from(Serialized::defaults(Config::default()))
1467 .merge(Toml::file(&base_path))
1468 .merge(Toml::file(&local_path))
1469 .merge(Env::prefixed("KOAN_").split("__"))
1470 }
1471
1472 pub fn load() -> Result<Self, ConfigError> {
1474 let cfg: Self = Self::figment()
1475 .extract()
1476 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
1477
1478 check_secrets_in_git();
1481
1482 Ok(cfg)
1483 }
1484
1485 pub fn load_or_default() -> Self {
1490 (*Self::cached()).clone()
1491 }
1492
1493 pub fn cached() -> Arc<Config> {
1500 let generation = CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst);
1501 let stamp = config_stamp();
1502 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
1503 && *seen == stamp
1504 {
1505 return cfg.clone();
1506 }
1507
1508 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
1509 log::warn!("failed to load config, using defaults: {}", e);
1510 Self::default()
1511 }));
1512 let mut cache = CONFIG_CACHE.write();
1517 if CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == generation
1518 && config_stamp() == stamp
1519 {
1520 *cache = Some((stamp, cfg.clone()));
1521 }
1522 cfg
1523 }
1524
1525 pub fn invalidate_cache() {
1529 let mut cache = CONFIG_CACHE.write();
1530 CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1531 *cache = None;
1532 }
1533
1534 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1536 let contents = fs::read_to_string(path)?;
1537 let config: Config = toml::from_str(&contents)?;
1538 Ok(config)
1539 }
1540
1541 fn from_files() -> Result<Self, ConfigError> {
1547 Figment::from(Serialized::defaults(Config::default()))
1548 .merge(Toml::file(config_file_path()))
1549 .merge(Toml::file(config_local_file_path()))
1550 .extract()
1551 .map_err(|e| ConfigError::Figment(Box::new(e)))
1552 }
1553
1554 pub fn set_in_files(path: &str) -> bool {
1557 [config_file_path(), config_local_file_path()]
1558 .iter()
1559 .any(|file| {
1560 let Ok(doc) = read_document(file) else {
1561 return true;
1563 };
1564 let mut item = doc.as_item();
1565 for key in path.split('.') {
1566 match item.get(key) {
1567 Some(next) => item = next,
1568 None => return false,
1569 }
1570 }
1571 true
1572 })
1573 }
1574
1575 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
1587 where
1588 F: FnOnce(&mut Config),
1589 {
1590 static ONE_AT_A_TIME: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
1594 let _one = ONE_AT_A_TIME.lock();
1595 let before = Self::from_files()?;
1596 let mut after = before.clone();
1597 mutate(&mut after);
1598
1599 let mut changes = Vec::new();
1600 diff_into(
1601 "",
1602 &toml::Value::try_from(&before)?,
1603 &toml::Value::try_from(&after)?,
1604 &mut changes,
1605 );
1606 if changes.is_empty() {
1607 return Ok(());
1608 }
1609
1610 let base_path = config_file_path();
1611 let local_path = config_local_file_path();
1612 let mut base = read_document(&base_path)?;
1613 let mut local = read_document(&local_path)?;
1614
1615 for (path, value) in &changes {
1616 let (target, other) = match layer_of(path) {
1617 Layer::Shared => (&mut base, &mut local),
1618 Layer::Machine => (&mut local, &mut base),
1619 };
1620 match value {
1621 Some(v) => doc_set(target, path, v),
1622 None => doc_remove(target, path),
1623 }
1624 doc_remove(other, path);
1625 }
1626
1627 write_document(&base_path, &base, false)?;
1628 write_document(&local_path, &local, true)?;
1629 Self::invalidate_cache();
1630 Ok(())
1631 }
1632
1633 pub fn cache_dir(&self) -> PathBuf {
1635 self.remote
1636 .cache_dir
1637 .clone()
1638 .unwrap_or_else(default_cache_dir)
1639 }
1640
1641 pub fn cache_limit_bytes(&self) -> Option<u64> {
1643 self.remote
1644 .cache_limit
1645 .as_deref()
1646 .and_then(parse_size_bytes)
1647 }
1648}
1649
1650fn diff_into(
1655 prefix: &str,
1656 before: &toml::Value,
1657 after: &toml::Value,
1658 out: &mut Vec<(String, Option<toml::Value>)>,
1659) {
1660 let (b, a) = match (before.as_table(), after.as_table()) {
1661 (Some(b), Some(a)) => (b, a),
1662 _ => {
1663 if before != after {
1664 out.push((prefix.to_string(), Some(after.clone())));
1665 }
1666 return;
1667 }
1668 };
1669
1670 let empty = toml::Value::Table(toml::map::Map::new());
1671 for key in b
1672 .keys()
1673 .chain(a.keys())
1674 .collect::<std::collections::BTreeSet<_>>()
1675 {
1676 let path = if prefix.is_empty() {
1677 key.clone()
1678 } else {
1679 format!("{prefix}.{key}")
1680 };
1681 match (b.get(key), a.get(key)) {
1682 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
1683 (None, Some(av)) => diff_into(&path, &empty, av, out),
1686 (Some(_), None) => out.push((path, None)),
1687 (None, None) => unreachable!("key came from one of the two tables"),
1688 }
1689 }
1690}
1691
1692fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
1693 let Ok(contents) = fs::read_to_string(path) else {
1694 return Ok(toml_edit::DocumentMut::new());
1695 };
1696 contents
1697 .parse::<toml_edit::DocumentMut>()
1698 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
1699}
1700
1701fn write_document(
1704 path: &Path,
1705 doc: &toml_edit::DocumentMut,
1706 secret: bool,
1707) -> Result<(), ConfigError> {
1708 let contents = doc.to_string();
1709 if contents.trim().is_empty() && !path.exists() {
1710 return Ok(());
1711 }
1712 if let Some(parent) = path.parent() {
1713 fs::create_dir_all(parent)?;
1714 }
1715 let target = link_target(path);
1718 let temp = unique_beside(&target);
1719 let written = write_new(&temp, &target, contents.as_bytes(), secret)
1720 .and_then(|()| fs::rename(&temp, &target));
1721 if let Err(e) = written {
1722 let _ = fs::remove_file(&temp);
1723 return Err(e.into());
1724 }
1725 #[cfg(target_os = "tvos")]
1726 kept::written(path, contents.as_bytes());
1727 Ok(())
1728}
1729
1730fn link_target(path: &Path) -> PathBuf {
1733 let mut target = path.to_path_buf();
1734 for _ in 0..40 {
1735 let Ok(next) = fs::read_link(&target) else {
1736 break;
1737 };
1738 target = match target.parent() {
1739 Some(parent) if next.is_relative() => parent.join(next),
1740 _ => next,
1741 };
1742 }
1743 target
1744}
1745
1746fn unique_beside(target: &Path) -> PathBuf {
1749 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1750 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1751 let name = target
1752 .file_name()
1753 .map(|n| n.to_string_lossy().into_owned())
1754 .unwrap_or_default();
1755 target.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
1756}
1757
1758fn write_new(temp: &Path, target: &Path, contents: &[u8], secret: bool) -> std::io::Result<()> {
1762 use std::io::Write as _;
1763 let mut options = fs::OpenOptions::new();
1764 options.write(true).create_new(true);
1765 #[cfg(unix)]
1766 let kept_mode = {
1767 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1768 let kept_mode = (!secret)
1769 .then(|| fs::metadata(target).ok())
1770 .flatten()
1771 .map(|m| m.permissions().mode() & 0o7777);
1772 options.mode(if secret {
1773 0o600
1774 } else {
1775 kept_mode.unwrap_or(0o666)
1776 });
1777 kept_mode
1778 };
1779 #[cfg(not(unix))]
1780 let _ = (target, secret);
1781 let mut file = options.open(temp)?;
1782 file.write_all(contents)?;
1783 #[cfg(unix)]
1786 if let Some(mode) = kept_mode {
1787 use std::os::unix::fs::PermissionsExt;
1788 file.set_permissions(fs::Permissions::from_mode(mode))?;
1789 }
1790 Ok(())
1791}
1792
1793fn implicit_table() -> toml_edit::Item {
1794 let mut table = toml_edit::Table::new();
1795 table.set_implicit(true);
1798 toml_edit::Item::Table(table)
1799}
1800
1801fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
1802 let segments: Vec<&str> = path.split('.').collect();
1803 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1804
1805 let mut table = doc.as_table_mut();
1806 for segment in parents {
1807 let item = table.entry(segment).or_insert_with(implicit_table);
1808 if !item.is_table() {
1811 *item = implicit_table();
1812 }
1813 table = item.as_table_mut().expect("just ensured it is a table");
1814 }
1815 match table.get_mut(last) {
1818 Some(existing) => *existing = to_edit_item(value),
1819 None => {
1820 table.insert(last, to_edit_item(value));
1821 }
1822 }
1823}
1824
1825fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
1828 let toml::Value::Array(items) = value else {
1829 return toml_edit::value(to_edit_value(value));
1830 };
1831 if items.is_empty() || !items.iter().all(toml::Value::is_table) {
1832 return toml_edit::value(to_edit_value(value));
1833 }
1834 let mut sections = toml_edit::ArrayOfTables::new();
1835 for item in items {
1836 let mut section = toml_edit::Table::new();
1837 for (k, v) in item.as_table().expect("checked above") {
1838 let mut v = to_edit_value(v);
1839 if let toml_edit::Value::Array(list) = &mut v
1840 && list.iter().any(toml_edit::Value::is_inline_table)
1841 {
1842 for entry in list.iter_mut() {
1843 entry.decor_mut().set_prefix("\n ");
1844 if let Some(t) = entry.as_inline_table_mut() {
1845 t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1846 }
1847 }
1848 list.set_trailing("\n");
1849 list.set_trailing_comma(true);
1850 }
1851 section.insert(k, toml_edit::value(v));
1852 }
1853 section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1854 sections.push(section);
1855 }
1856 toml_edit::Item::ArrayOfTables(sections)
1857}
1858
1859fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
1861 let k = key.get();
1862 (if k == "name" || k == "type" { 0 } else { 1 }, k)
1863}
1864
1865fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
1866 let segments: Vec<&str> = path.split('.').collect();
1867 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1868
1869 let mut table = doc.as_table_mut();
1870 for segment in parents {
1871 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
1872 Some(child) => table = child,
1873 None => return,
1874 }
1875 }
1876 table.remove(last);
1879}
1880
1881fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
1882 match value {
1883 toml::Value::String(s) => s.as_str().into(),
1884 toml::Value::Integer(i) => (*i).into(),
1885 toml::Value::Float(f) => (*f).into(),
1886 toml::Value::Boolean(b) => (*b).into(),
1887 toml::Value::Datetime(d) => d.to_string().into(),
1888 toml::Value::Array(items) => items
1889 .iter()
1890 .map(to_edit_value)
1891 .collect::<toml_edit::Array>()
1892 .into(),
1893 toml::Value::Table(t) => {
1894 let mut inline = toml_edit::InlineTable::new();
1895 for (k, v) in t {
1896 inline.insert(k, to_edit_value(v));
1897 }
1898 inline.into()
1899 }
1900 }
1901}
1902
1903pub fn config_dir() -> PathBuf {
1912 if let Some(dir) = CONFIG_DIR.read().clone() {
1913 return dir;
1914 }
1915 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
1916 return PathBuf::from(dir);
1917 }
1918 platform_config_dir()
1919}
1920
1921#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
1922fn platform_config_dir() -> PathBuf {
1923 dirs::home_dir()
1924 .unwrap_or_else(|| PathBuf::from("."))
1925 .join(".config")
1926 .join("koan")
1927}
1928
1929#[cfg(target_os = "ios")]
1933fn platform_config_dir() -> PathBuf {
1934 ios_library().join("Application Support").join("koan")
1935}
1936
1937#[cfg(target_os = "tvos")]
1942fn platform_config_dir() -> PathBuf {
1943 let dir = ios_library().join("Caches").join("koan-config");
1944 kept::restore(&dir);
1945 dir
1946}
1947
1948#[cfg(target_os = "tvos")]
1957mod kept {
1958 use std::fs;
1959 use std::os::unix::fs::PermissionsExt;
1960 use std::path::Path;
1961 use std::sync::Once;
1962
1963 use core_foundation::base::TCFType;
1964 use core_foundation::data::{CFData, CFDataRef};
1965 use core_foundation::string::CFString;
1966 use core_foundation_sys::base::{CFGetTypeID, CFRelease};
1967 use core_foundation_sys::data::CFDataGetTypeID;
1968 use core_foundation_sys::preferences::{
1969 CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesSetAppValue,
1970 kCFPreferencesCurrentApplication,
1971 };
1972
1973 const FILES: [&str; 2] = ["config.toml", "config.local.toml"];
1974
1975 pub(super) fn restore(dir: &Path) {
1979 static ONCE: Once = Once::new();
1980 ONCE.call_once(|| {
1981 for name in FILES {
1982 let path = dir.join(name);
1983 match fs::read(&path) {
1984 Ok(contents) => save(name, &contents),
1985 Err(_) => {
1986 let Some(contents) = load(name) else { continue };
1987 let _ = fs::create_dir_all(dir);
1988 if fs::write(&path, &contents).is_ok() {
1989 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
1990 log::info!("config: restored {name} after the system cleared it");
1991 }
1992 }
1993 }
1994 }
1995 });
1996 }
1997
1998 pub(super) fn written(path: &Path, contents: &[u8]) {
2001 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2002 return;
2003 };
2004 if FILES.contains(&name) && path.parent() == Some(super::platform_config_dir().as_path()) {
2005 save(name, contents);
2006 }
2007 }
2008
2009 fn save(name: &str, contents: &[u8]) {
2010 let key = CFString::new(name);
2011 let data = CFData::from_buffer(contents);
2012 unsafe {
2015 CFPreferencesSetAppValue(
2016 key.as_concrete_TypeRef(),
2017 data.as_CFTypeRef(),
2018 kCFPreferencesCurrentApplication,
2019 );
2020 CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
2021 }
2022 }
2023
2024 fn load(name: &str) -> Option<Vec<u8>> {
2025 let key = CFString::new(name);
2026 unsafe {
2029 let value = CFPreferencesCopyAppValue(
2030 key.as_concrete_TypeRef(),
2031 kCFPreferencesCurrentApplication,
2032 );
2033 if value.is_null() {
2034 return None;
2035 }
2036 if CFGetTypeID(value) != CFDataGetTypeID() {
2037 CFRelease(value);
2038 return None;
2039 }
2040 Some(
2041 CFData::wrap_under_create_rule(value as CFDataRef)
2042 .bytes()
2043 .to_vec(),
2044 )
2045 }
2046 }
2047}
2048
2049#[cfg(any(target_os = "ios", target_os = "tvos"))]
2050fn ios_library() -> PathBuf {
2051 dirs::home_dir()
2052 .unwrap_or_else(|| PathBuf::from("."))
2053 .join("Library")
2054}
2055
2056fn default_cache_dir() -> PathBuf {
2063 #[cfg(any(target_os = "ios", target_os = "tvos"))]
2064 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
2065 return ios_library().join("Caches").join("koan");
2066 }
2067 config_dir().join("cache")
2068}
2069
2070pub fn set_config_dir(dir: impl Into<PathBuf>) {
2077 *CONFIG_DIR.write() = Some(dir.into());
2078 Config::invalidate_cache();
2079}
2080
2081pub fn isolate_config_for_tests() {
2097 let _one = SWITCHING.lock().unwrap_or_else(|e| e.into_inner());
2098 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
2099 let _ = fs::create_dir_all(&dir);
2100 set_config_dir(dir);
2101}
2102
2103#[doc(hidden)]
2107pub static SWITCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2108
2109static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
2110 LazyLock::new(|| parking_lot::RwLock::new(None));
2111
2112pub fn config_file_path() -> PathBuf {
2114 config_dir().join("config.toml")
2115}
2116
2117pub fn config_local_file_path() -> PathBuf {
2119 config_dir().join("config.local.toml")
2120}
2121
2122pub fn db_path() -> PathBuf {
2124 config_dir().join("koan.db")
2125}
2126
2127const LOG_LIMIT: u64 = 16 * 1024 * 1024;
2130const LOG_CHECK_EVERY: u32 = 4096;
2133
2134#[derive(Default)]
2136pub struct LogFile {
2137 file: Option<fs::File>,
2138 lines: u32,
2139}
2140
2141impl LogFile {
2142 pub fn write(&mut self, line: std::fmt::Arguments) {
2143 use std::io::Write as _;
2144 if self.file.is_none() {
2145 self.file = open_log();
2146 }
2147 let Some(file) = self.file.as_mut() else {
2148 return;
2149 };
2150 let _ = writeln!(file, "{line}");
2151 self.lines = self.lines.wrapping_add(1);
2152 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
2153 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
2154 {
2155 self.file = open_log();
2156 }
2157 }
2158
2159 pub fn flush(&mut self) {
2160 if let Some(file) = self.file.as_mut() {
2161 let _ = std::io::Write::flush(file);
2162 }
2163 }
2164}
2165
2166fn open_log() -> Option<fs::File> {
2172 let dir = config_dir();
2173 fs::create_dir_all(&dir).ok()?;
2174 let path = dir.join("koan.log");
2175 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
2176 let _ = fs::rename(&path, dir.join("koan.log.1"));
2177 }
2178 fs::OpenOptions::new()
2179 .create(true)
2180 .append(true)
2181 .open(path)
2182 .ok()
2183}
2184
2185fn check_secrets_in_git() {
2192 static ONCE: Once = Once::new();
2193 ONCE.call_once(scan_for_tracked_secrets);
2194}
2195
2196fn scan_for_tracked_secrets() {
2197 let sensitive_fields = ["password", "api_key", "refresh_token"];
2198
2199 for (label, path) in [
2200 ("config.toml", config_file_path()),
2201 ("config.local.toml", config_local_file_path()),
2202 ] {
2203 let Ok(contents) = std::fs::read_to_string(&path) else {
2204 continue;
2205 };
2206
2207 let has_secrets = sensitive_fields.iter().any(|field| {
2209 contents.lines().any(|line| {
2210 let line = line.trim();
2211 if let Some(rest) = line.strip_prefix(field) {
2212 let rest = rest.trim_start();
2213 if let Some(value) = rest.strip_prefix('=') {
2214 let value = value.trim().trim_matches('"').trim_matches('\'');
2215 return !value.is_empty();
2216 }
2217 }
2218 false
2219 })
2220 });
2221
2222 if !has_secrets {
2223 continue;
2224 }
2225
2226 if is_tracked_by_git(&path) {
2228 eprintln!();
2229 eprintln!("╔══════════════════════════════════════════════════════════════╗");
2230 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
2231 eprintln!("╠══════════════════════════════════════════════════════════════╣");
2232 eprintln!("║ ║");
2233 eprintln!("║ File: {:<52} ║", path.display());
2234 eprintln!("║ ║");
2235 eprintln!("║ Your password is in version control. You should: ║");
2236 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
2237 eprintln!("║ 2. Add it to .gitignore ║");
2238 eprintln!("║ 3. Rotate your credentials immediately ║");
2239 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
2240 eprintln!("║ `koan remote login` writes there for you ║");
2241 eprintln!("║ ║");
2242 eprintln!("╚══════════════════════════════════════════════════════════════╝");
2243 eprintln!();
2244 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
2245 }
2246 }
2247}
2248
2249fn is_tracked_by_git(path: &Path) -> bool {
2251 let Some(parent) = path.parent() else {
2252 return false;
2253 };
2254 std::process::Command::new("git")
2256 .args(["ls-files", "--error-unmatch"])
2257 .arg(path)
2258 .current_dir(parent)
2259 .stdout(std::process::Stdio::null())
2260 .stderr(std::process::Stdio::null())
2261 .status()
2262 .is_ok_and(|s| s.success())
2263}
2264
2265#[cfg(test)]
2266pub(crate) mod tests {
2267 use super::*;
2268 use std::fs;
2269
2270 fn tmp_dir() -> PathBuf {
2271 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
2272 fs::create_dir_all(&dir).unwrap();
2273 dir
2274 }
2275
2276 #[test]
2277 fn test_defaults() {
2278 let cfg = Config::default();
2279 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2280 assert!(!cfg.remote.enabled);
2281 }
2282
2283 #[test]
2284 fn test_roundtrip_toml() {
2285 let cfg = Config::default();
2286 let serialized = toml::to_string_pretty(&cfg).unwrap();
2287 let deserialized: Config = toml::from_str(&serialized).unwrap();
2288 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
2289 assert_eq!(
2290 deserialized.remote.download_workers,
2291 cfg.remote.download_workers
2292 );
2293 }
2294
2295 #[test]
2296 fn test_load_from_file() {
2297 let dir = tempfile::tempdir().unwrap();
2298 let path = dir.path().join("config.toml");
2299 fs::write(
2300 &path,
2301 r#"
2302[library]
2303folders = ["/tmp/music"]
2304
2305[playback]
2306replaygain = "track"
2307"#,
2308 )
2309 .unwrap();
2310
2311 let cfg = Config::load_from(&path).unwrap();
2312 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
2313 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
2314 assert!(!cfg.remote.enabled);
2315 }
2316
2317 #[test]
2318 fn test_partial_toml_uses_defaults() {
2319 let dir = tempfile::tempdir().unwrap();
2320 let path = dir.path().join("partial.toml");
2321 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
2322
2323 let cfg = Config::load_from(&path).unwrap();
2324 assert_eq!(cfg.playback.target_fps, 30);
2325 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2326 }
2327
2328 #[test]
2329 fn test_figment_layered_loading() {
2330 let dir = tempfile::tempdir().unwrap();
2331 let base_path = dir.path().join("config.toml");
2332 let local_path = dir.path().join("config.local.toml");
2333
2334 fs::write(
2335 &base_path,
2336 r#"
2337[remote]
2338url = "https://base.example.com"
2339"#,
2340 )
2341 .unwrap();
2342 fs::write(
2343 &local_path,
2344 r#"
2345[remote]
2346enabled = true
2347url = "https://local.example.com"
2348username = "admin"
2349password = "secret"
2350"#,
2351 )
2352 .unwrap();
2353
2354 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2356 .merge(Toml::file(&base_path))
2357 .merge(Toml::file(&local_path))
2358 .extract()
2359 .unwrap();
2360
2361 assert!(cfg.remote.enabled);
2362 assert_eq!(cfg.remote.url, "https://local.example.com");
2363 assert_eq!(cfg.remote.username, "admin");
2364 assert_eq!(cfg.remote.password, "secret");
2365 }
2366
2367 #[test]
2368 fn test_figment_missing_keys_preserved() {
2369 let dir = tempfile::tempdir().unwrap();
2370 let base_path = dir.path().join("config.toml");
2371 let local_path = dir.path().join("config.local.toml");
2372
2373 fs::write(
2374 &base_path,
2375 r#"
2376[remote]
2377url = "https://keep.me"
2378username = "keepuser"
2379"#,
2380 )
2381 .unwrap();
2382 fs::write(
2383 &local_path,
2384 r#"
2385[remote]
2386password = "secret"
2387"#,
2388 )
2389 .unwrap();
2390
2391 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2392 .merge(Toml::file(&base_path))
2393 .merge(Toml::file(&local_path))
2394 .extract()
2395 .unwrap();
2396
2397 assert_eq!(cfg.remote.url, "https://keep.me");
2398 assert_eq!(cfg.remote.username, "keepuser");
2399 assert_eq!(cfg.remote.password, "secret");
2400 }
2401
2402 #[test]
2403 fn test_env_var_override() {
2404 let dir = tempfile::tempdir().unwrap();
2405 let base_path = dir.path().join("config.toml");
2406
2407 fs::write(
2408 &base_path,
2409 r#"
2410[remote]
2411url = "https://file.example.com"
2412"#,
2413 )
2414 .unwrap();
2415
2416 unsafe {
2418 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
2419 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
2420 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
2421 }
2422
2423 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2424 .merge(Toml::file(&base_path))
2425 .merge(Env::prefixed("KOAN_").split("__"))
2426 .extract()
2427 .unwrap();
2428
2429 assert_eq!(cfg.remote.url, "https://env.example.com");
2430 assert_eq!(cfg.remote.password, "env-secret");
2431 assert_eq!(cfg.graphql.port, 9999);
2432
2433 unsafe {
2435 std::env::remove_var("KOAN_REMOTE__URL");
2436 std::env::remove_var("KOAN_REMOTE__PASSWORD");
2437 std::env::remove_var("KOAN_GRAPHQL__PORT");
2438 }
2439 }
2440
2441 #[test]
2442 fn test_cache_dir_default() {
2443 let cfg = Config::default();
2444 assert!(cfg.cache_dir().ends_with("cache"));
2445 }
2446
2447 #[test]
2448 fn test_cache_dir_explicit() {
2449 let mut cfg = Config::default();
2450 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
2451 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
2452 }
2453
2454 #[test]
2455 fn test_organize_config_defaults() {
2456 let cfg = Config::default();
2457 assert!(cfg.organize.default.is_none());
2458 assert!(cfg.organize.patterns.is_empty());
2459 }
2460
2461 #[test]
2462 fn test_organize_config_from_toml() {
2463 let dir = tmp_dir();
2464 let path = dir.join("organize.toml");
2465 fs::write(
2466 &path,
2467 r#"
2468[organize]
2469default = "standard"
2470
2471[organize.patterns]
2472standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
2473va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
2474"#,
2475 )
2476 .unwrap();
2477
2478 let cfg = Config::load_from(&path).unwrap();
2479 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
2480 assert_eq!(cfg.organize.patterns.len(), 2);
2481 assert!(cfg.organize.patterns.contains_key("standard"));
2482 assert!(cfg.organize.patterns.contains_key("va-aware"));
2483
2484 fs::remove_dir_all(&dir).ok();
2485 }
2486
2487 #[test]
2488 fn test_figment_organize_patterns_merge() {
2489 let dir = tempfile::tempdir().unwrap();
2490 let base_path = dir.path().join("config.toml");
2491 let local_path = dir.path().join("config.local.toml");
2492
2493 fs::write(
2494 &base_path,
2495 r#"
2496[organize]
2497default = "standard"
2498
2499[organize.patterns]
2500standard = "base-pattern"
2501"#,
2502 )
2503 .unwrap();
2504 fs::write(
2505 &local_path,
2506 r#"
2507[organize]
2508default = "custom"
2509
2510[organize.patterns]
2511custom = "local-pattern"
2512"#,
2513 )
2514 .unwrap();
2515
2516 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2517 .merge(Toml::file(&base_path))
2518 .merge(Toml::file(&local_path))
2519 .extract()
2520 .unwrap();
2521
2522 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
2524 assert_eq!(cfg.organize.patterns.len(), 2);
2526 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
2527 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
2528 }
2529
2530 #[test]
2531 fn test_output_device_config_roundtrip() {
2532 let mut cfg = Config::default();
2533 cfg.playback.output_device = Some("My DAC".into());
2534
2535 let serialized = toml::to_string_pretty(&cfg).unwrap();
2536 let deserialized: Config = toml::from_str(&serialized).unwrap();
2537 assert_eq!(
2538 deserialized.playback.output_device.as_deref(),
2539 Some("My DAC")
2540 );
2541 }
2542
2543 #[test]
2544 fn test_output_device_config_default_is_none() {
2545 let cfg = Config::default();
2546 assert!(cfg.playback.output_device.is_none());
2547
2548 let serialized = toml::to_string_pretty(&cfg).unwrap();
2550 assert!(!serialized.contains("output_device"));
2551 let deserialized: Config = toml::from_str(&serialized).unwrap();
2552 assert!(deserialized.playback.output_device.is_none());
2553 }
2554
2555 #[test]
2556 fn test_output_device_config_from_toml() {
2557 let dir = tempfile::tempdir().unwrap();
2558 let path = dir.path().join("config.toml");
2559 fs::write(
2560 &path,
2561 r#"
2562[playback]
2563output_device = "External Speakers"
2564"#,
2565 )
2566 .unwrap();
2567
2568 let cfg = Config::load_from(&path).unwrap();
2569 assert_eq!(
2570 cfg.playback.output_device.as_deref(),
2571 Some("External Speakers")
2572 );
2573 }
2574
2575 #[test]
2576 fn test_graphql_bind_defaults_to_localhost() {
2577 let cfg = GraphqlConfig::default();
2578 assert_eq!(
2579 cfg.bind,
2580 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2581 );
2582 }
2583
2584 #[test]
2585 fn test_graphql_bind_from_toml() {
2586 let toml_str = r#"
2587[graphql]
2588bind = "0.0.0.0"
2589port = 5000
2590"#;
2591 let cfg: Config = toml::from_str(toml_str).unwrap();
2592 assert_eq!(
2593 cfg.graphql.bind,
2594 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
2595 );
2596 assert_eq!(cfg.graphql.port, 5000);
2597 }
2598
2599 #[test]
2600 fn test_graphql_bind_omitted_defaults_to_localhost() {
2601 let toml_str = r#"
2602[graphql]
2603port = 4000
2604"#;
2605 let cfg: Config = toml::from_str(toml_str).unwrap();
2606 assert_eq!(
2607 cfg.graphql.bind,
2608 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2609 );
2610 }
2611
2612 #[test]
2613 fn test_organize_config_roundtrip() {
2614 let mut cfg = Config::default();
2615 cfg.organize.default = Some("standard".into());
2616 cfg.organize
2617 .patterns
2618 .insert("standard".into(), "%artist%/%title%".into());
2619
2620 let serialized = toml::to_string_pretty(&cfg).unwrap();
2621 let deserialized: Config = toml::from_str(&serialized).unwrap();
2622 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
2623 assert_eq!(
2624 deserialized.organize.patterns["standard"],
2625 "%artist%/%title%"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_parse_size_bytes() {
2631 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
2632 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
2633 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
2634 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
2635 assert_eq!(parse_size_bytes("1024B"), Some(1024));
2636 assert_eq!(parse_size_bytes("1024"), Some(1024));
2637
2638 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
2640 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
2641
2642 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
2644 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
2645
2646 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
2648 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
2649
2650 assert_eq!(
2652 parse_size_bytes("1.5GB"),
2653 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
2654 );
2655
2656 assert_eq!(parse_size_bytes(""), None);
2658 assert_eq!(parse_size_bytes("abc"), None);
2659 assert_eq!(parse_size_bytes("50XB"), None);
2660 }
2661
2662 #[test]
2663 fn test_cache_limit_config_from_toml() {
2664 let toml_str = r#"
2665[remote]
2666cache_limit = "50GB"
2667"#;
2668 let cfg: Config = toml::from_str(toml_str).unwrap();
2669 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
2670 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
2671 }
2672
2673 #[test]
2674 fn test_cache_limit_none_by_default() {
2675 let cfg = Config::default();
2676 assert!(cfg.remote.cache_limit.is_none());
2677 assert!(cfg.cache_limit_bytes().is_none());
2678 }
2679
2680 #[test]
2681 fn test_cache_limit_not_serialized_when_none() {
2682 let cfg = Config::default();
2683 let serialized = toml::to_string_pretty(&cfg).unwrap();
2684 assert!(!serialized.contains("cache_limit"));
2685 }
2686
2687 #[test]
2688 fn player_uses_config_on_init() {
2689 let dir = tempfile::tempdir().unwrap();
2693 let path = dir.path().join("config.toml");
2694 fs::write(
2695 &path,
2696 r#"
2697[playback]
2698replaygain = "track"
2699output_device = "My Fancy DAC"
2700pre_amp_db = -3.5
2701target_fps = 30
2702art_size = 32
2703
2704[visualizer]
2705enabled = false
2706mode = "oscilloscope"
2707fps = 30
2708"#,
2709 )
2710 .unwrap();
2711
2712 let cfg = Config::load_from(&path).unwrap();
2713
2714 assert_eq!(
2716 cfg.playback.replaygain,
2717 ReplayGainMode::Track,
2718 "replaygain should be 'track'"
2719 );
2720 assert_eq!(
2721 cfg.playback.output_device.as_deref(),
2722 Some("My Fancy DAC"),
2723 "output_device should match config"
2724 );
2725 assert!(
2726 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
2727 "pre_amp_db should be -3.5"
2728 );
2729 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
2730 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
2731
2732 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
2734 assert_eq!(cfg.visualizer.mode, "oscilloscope");
2735 assert_eq!(cfg.visualizer.fps, 30);
2736 }
2737
2738 #[test]
2739 fn a_missing_config_file_stamps_as_absent() {
2740 let dir = tempfile::tempdir().unwrap();
2741 let base = dir.path().join("config.toml");
2742 let local = dir.path().join("config.local.toml");
2743
2744 assert_eq!(stamp_of(&base, &local), (None, None));
2745
2746 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
2747 let (base_stamp, local_stamp) = stamp_of(&base, &local);
2748 assert!(base_stamp.is_some(), "creating the file must be a change");
2749 assert!(local_stamp.is_none());
2750 }
2751
2752 #[test]
2753 fn editing_a_config_file_changes_its_stamp() {
2754 let dir = tempfile::tempdir().unwrap();
2755 let base = dir.path().join("config.toml");
2756 let local = dir.path().join("config.local.toml");
2757 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
2758
2759 let before = stamp_of(&base, &local);
2760 std::thread::sleep(std::time::Duration::from_millis(20));
2762 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
2763
2764 assert_ne!(
2765 before,
2766 stamp_of(&base, &local),
2767 "a config edited by hand has to be picked up"
2768 );
2769 }
2770
2771 #[test]
2772 fn invalidating_forces_a_reload() {
2773 let first = Config::cached();
2774 Config::invalidate_cache();
2775 assert!(
2776 !Arc::ptr_eq(&first, &Config::cached()),
2777 "koan's own writes invalidate explicitly; the next read must re-parse"
2778 );
2779 }
2780
2781 pub(crate) static PERSIST_LOCK: &std::sync::Mutex<()> = &super::SWITCHING;
2786
2787 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
2789 let dir =
2790 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
2791 let _ = fs::remove_dir_all(&dir);
2792 fs::create_dir_all(&dir).unwrap();
2793 set_config_dir(&dir);
2794 (config_file_path(), config_local_file_path())
2795 }
2796
2797 #[cfg(unix)]
2801 #[test]
2802 fn a_write_keeps_links_modes_and_no_leftovers() {
2803 use std::os::unix::fs::PermissionsExt;
2804 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2805 let dir = tempfile::tempdir().unwrap();
2806 let dotfiles = tempfile::tempdir().unwrap();
2807 std::os::unix::fs::symlink(
2808 dotfiles.path().join("config.toml"),
2809 dir.path().join("config.toml"),
2810 )
2811 .unwrap();
2812 set_config_dir(dir.path());
2813
2814 Config::persist(|c| c.graphql.port = 4100).unwrap();
2815 assert!(
2816 fs::symlink_metadata(config_file_path())
2817 .unwrap()
2818 .is_symlink()
2819 );
2820 assert!(
2821 fs::read_to_string(dotfiles.path().join("config.toml"))
2822 .unwrap()
2823 .contains("4100")
2824 );
2825
2826 fs::set_permissions(
2827 dotfiles.path().join("config.toml"),
2828 fs::Permissions::from_mode(0o640),
2829 )
2830 .unwrap();
2831 Config::persist(|c| {
2832 c.graphql.port = 4101;
2833 c.remote.password = "hunter2".into();
2834 })
2835 .unwrap();
2836 let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
2837 assert_eq!(mode(dotfiles.path().join("config.toml")), 0o640);
2838 assert_eq!(mode(config_local_file_path()), 0o600);
2839 assert!(
2840 fs::symlink_metadata(config_file_path())
2841 .unwrap()
2842 .is_symlink()
2843 );
2844
2845 let names = |d: &Path| -> Vec<String> {
2846 fs::read_dir(d)
2847 .unwrap()
2848 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2849 .collect()
2850 };
2851 assert!(!names(dir.path()).iter().any(|n| n.ends_with(".tmp")));
2852 assert_eq!(names(dotfiles.path()), vec!["config.toml".to_string()]);
2853 }
2854
2855 #[test]
2859 fn a_read_during_switches_is_never_kept_for_the_wrong_directory() {
2860 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2861 let dirs: Vec<tempfile::TempDir> = (0..2).map(|_| tempfile::tempdir().unwrap()).collect();
2862 for (dir, port) in dirs.iter().zip([4001, 4002]) {
2863 fs::write(
2864 dir.path().join("config.toml"),
2865 format!("[graphql]\nport = {port}\n"),
2866 )
2867 .unwrap();
2868 }
2869 let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
2870 let reader = std::thread::spawn({
2871 let done = done.clone();
2872 move || {
2873 while !done.load(std::sync::atomic::Ordering::Relaxed) {
2874 Config::cached();
2875 }
2876 }
2877 });
2878 for i in 0..2000 {
2879 let (dir, port) = if i % 2 == 0 {
2880 (&dirs[0], 4001)
2881 } else {
2882 (&dirs[1], 4002)
2883 };
2884 set_config_dir(dir.path());
2885 for _ in 0..3 {
2886 assert_eq!(Config::cached().graphql.port, port, "switch {i}");
2887 }
2888 }
2889 done.store(true, std::sync::atomic::Ordering::Relaxed);
2890 reader.join().unwrap();
2891 }
2892
2893 #[test]
2894 fn persist_keeps_comments_and_untouched_keys() {
2895 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2896 let (base, _local) = persist_sandbox("comments");
2897 fs::write(
2898 &base,
2899 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
2900 )
2901 .unwrap();
2902
2903 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
2904
2905 let written = fs::read_to_string(&base).unwrap();
2906 assert!(
2907 written.contains("# koan — shareable defaults"),
2908 "the header comment must survive a write: {written}"
2909 );
2910 assert!(
2911 written.contains("# fps = 60"),
2912 "commented-out defaults are the template's whole point: {written}"
2913 );
2914 assert!(written.contains("palette = \"neon\""));
2915 assert!(
2916 !written.contains("[graphql]"),
2917 "an untouched section must not be invented: {written}"
2918 );
2919 }
2920
2921 #[test]
2922 fn persist_routes_machine_settings_to_the_local_file() {
2923 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2924 let (base, local) = persist_sandbox("routing");
2925
2926 Config::persist(|cfg| {
2927 cfg.playback.replaygain = ReplayGainMode::Album;
2928 cfg.playback.output_device = Some("My DAC".into());
2929 cfg.playback.art_size = 40;
2930 cfg.visualizer.mode = "starfield".into();
2931 })
2932 .unwrap();
2933
2934 let shared = fs::read_to_string(&base).unwrap();
2935 let machine = fs::read_to_string(&local).unwrap();
2936
2937 assert!(shared.contains("replaygain = \"album\""), "{shared}");
2938 for machine_only in ["output_device", "art_size", "starfield"] {
2939 assert!(
2940 !shared.contains(machine_only),
2941 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
2942 );
2943 assert!(machine.contains(machine_only), "{machine}");
2944 }
2945 }
2946
2947 #[test]
2948 fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
2949 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2950 let (base, local) = persist_sandbox("dsp");
2951
2952 let profile = DspProfile {
2953 name: "HD 600".into(),
2954 devices: vec!["Topping E30".into()],
2955 preamp_db: None,
2956 filters: vec![
2957 DspFilter::Band(EqFilter {
2958 kind: EqFilterKind::Peaking,
2959 freq: 20.0,
2960 gain_db: -1.3,
2961 q: 2.0,
2962 channels: vec![],
2963 }),
2964 DspFilter::Band(EqFilter {
2965 kind: EqFilterKind::HighShelfFirstOrder,
2966 freq: 10000.0,
2967 gain_db: 2.5,
2968 q: 0.7,
2969 channels: vec![],
2970 }),
2971 DspFilter::Delay(Delay {
2972 ms: 1.5,
2973 channels: vec![1],
2974 ..Default::default()
2975 }),
2976 DspFilter::Mix(Mix {
2977 outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
2978 }),
2979 DspFilter::Graphic(GraphicEq {
2980 points: vec![(20.0, -1.0), (1000.0, 0.0)],
2981 channels: vec![],
2982 }),
2983 ],
2984 impulses: vec![],
2985 source: vec![],
2986 target: None,
2987 layers: vec![],
2988 group: false,
2989 preset: false,
2990 filters_only: false,
2991 scope: None,
2992 uid: None,
2993 origin: None,
2994 role: None,
2995 measurement: None,
2996 fitted: None,
2997 tuned_for: None,
2998 original: None,
2999 };
3000 Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
3001
3002 assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
3003 let machine = fs::read_to_string(&local).unwrap();
3004 assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
3005 assert!(
3006 machine.contains("\n { type = \"peaking\""),
3007 "one band per line: {machine}"
3008 );
3009 assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
3010 assert_eq!(
3011 Config::from_files()
3012 .unwrap()
3013 .dsp
3014 .profile_for("Topping E30")
3015 .map(|p| p.name.as_str()),
3016 Some("HD 600")
3017 );
3018 }
3019
3020 #[test]
3023 fn a_profile_is_played_within_bounds() {
3024 let band = |freq: f64, gain_db: f64, q: f64| {
3025 DspFilter::Band(EqFilter {
3026 kind: EqFilterKind::Peaking,
3027 freq,
3028 gain_db,
3029 q,
3030 channels: vec![],
3031 })
3032 };
3033 let mut p = DspProfile {
3034 name: "Loud\u{7}".into(),
3035 preamp_db: Some(60.0),
3036 filters: vec![
3037 DspFilter::Delay(Delay {
3038 ms: 1e12,
3039 ..Default::default()
3040 }),
3041 band(0.5, 200.0, 0.0),
3042 band(f64::NAN, 0.0, 1.0),
3043 DspFilter::Band(EqFilter {
3044 kind: EqFilterKind::Peaking,
3045 freq: 1000.0,
3046 gain_db: 0.0,
3047 q: 1.0,
3048 channels: vec![99],
3049 }),
3050 DspFilter::Mix(Mix {
3051 outputs: vec![vec![(0, 1e9), (1, f64::INFINITY)]],
3052 }),
3053 DspFilter::Graphic(GraphicEq {
3054 points: (0..3000).map(|i| (i as f64, 1.0)).collect(),
3055 channels: vec![],
3056 }),
3057 ],
3058 ..Default::default()
3059 };
3060 p.filters.extend((0..300).map(|_| band(1000.0, 1.0, 1.0)));
3061 let notes = p.sanitize();
3062 let has = |n: &str| notes.iter().any(|x| x == n);
3063 assert!(has("delay clamped to 2000 ms"), "{notes:?}");
3064 assert!(has("preamp clamped to 30 dB"), "{notes:?}");
3065 assert!(has("a band's gain clamped to 30 dB"), "{notes:?}");
3066 assert!(has("a band's frequency clamped to 1 Hz"), "{notes:?}");
3067 assert!(has("a filter that could not play dropped"), "{notes:?}");
3068 assert!(has("bands past 64 a channel dropped"), "{notes:?}");
3069 assert!(has("graphic EQ points past 2048 dropped"), "{notes:?}");
3070 assert_eq!(p.name, "Loud");
3071 assert_eq!(p.preamp_db, Some(30.0));
3072 assert_eq!(
3073 p.filters[0],
3074 DspFilter::Delay(Delay {
3075 ms: 2000.0,
3076 ..Default::default()
3077 })
3078 );
3079 assert_eq!(p.filters[1], band(1.0, 30.0, 0.01));
3080 let DspFilter::Mix(m) = &p.filters[2] else {
3081 panic!("{:?}", p.filters[2])
3082 };
3083 assert_eq!(m.outputs, vec![vec![(0, dsp_bounds::MIX_GAIN)]]);
3084 let bands = p
3085 .filters
3086 .iter()
3087 .filter(|f| matches!(f, DspFilter::Band(_)))
3088 .count();
3089 assert_eq!(bands, dsp_bounds::BANDS_PER_CHANNEL);
3090 assert!(p.clone().sanitize().is_empty(), "within bounds now");
3091 }
3092
3093 #[test]
3094 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
3095 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3096 let (base, _local) = persist_sandbox("folders");
3097
3098 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
3101
3102 let shared = fs::read_to_string(&base).unwrap_or_default();
3103 assert!(
3104 !shared.contains("folders"),
3105 "a visualiser toggle must not invent library folders: {shared}"
3106 );
3107 }
3108
3109 #[test]
3110 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
3111 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3112 let (base, local) = persist_sandbox("drain");
3113 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
3114
3115 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
3116
3117 let shared = fs::read_to_string(&base).unwrap();
3118 assert!(
3119 !shared.contains("art_size"),
3120 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
3121 );
3122 assert!(shared.contains("target_fps"), "{shared}");
3123 assert!(
3124 fs::read_to_string(&local)
3125 .unwrap()
3126 .contains("art_size = 48")
3127 );
3128 }
3129
3130 #[test]
3131 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
3132 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3133 let (_base, local) = persist_sandbox("shadow");
3134 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
3135
3136 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
3137
3138 assert_eq!(
3139 Config::from_files().unwrap().playback.target_fps,
3140 120,
3141 "local wins the merge, so a shared write over a local copy would \
3142 otherwise be silently ignored: {}",
3143 fs::read_to_string(&local).unwrap()
3144 );
3145 }
3146
3147 #[test]
3148 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
3149 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3150 let (base, _local) = persist_sandbox("noop");
3151 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
3152
3153 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
3154
3155 assert_eq!(
3156 fs::read_to_string(&base).unwrap(),
3157 "# untouched\n[playback]\ntarget_fps = 60\n"
3158 );
3159 }
3160
3161 #[test]
3162 fn persist_keeps_passwords_out_of_the_shared_file() {
3163 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3164 let (base, local) = persist_sandbox("secrets");
3165
3166 Config::persist(|cfg| {
3167 cfg.remote.password = "hunter2".into();
3168 cfg.subsonic.password = "s3cret".into();
3169 cfg.visualizer.palette = "mono".into();
3170 })
3171 .unwrap();
3172
3173 let shared = fs::read_to_string(&base).unwrap();
3174 assert!(!shared.contains("hunter2"), "{shared}");
3175 assert!(!shared.contains("s3cret"), "{shared}");
3176 assert!(shared.contains("mono"));
3177
3178 let machine = fs::read_to_string(&local).unwrap();
3179 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
3180 }
3181
3182 #[test]
3183 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
3184 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3185 let (_base, local) = persist_sandbox("clear-secret");
3186 fs::write(
3187 &local,
3188 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
3189 )
3190 .unwrap();
3191
3192 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
3193
3194 let machine = fs::read_to_string(&local).unwrap();
3195 assert!(
3196 !machine.contains("password"),
3197 "an emptied secret should leave no key behind: {machine}"
3198 );
3199 assert!(machine.contains("url"), "{machine}");
3200 }
3201
3202 #[test]
3203 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
3204 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3205 let (base, _local) = persist_sandbox("patterns");
3206 fs::write(
3207 &base,
3208 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
3209 )
3210 .unwrap();
3211
3212 Config::persist(|cfg| {
3213 cfg.organize
3214 .patterns
3215 .insert("standard".into(), "%album artist%/%album%".into());
3216 })
3217 .unwrap();
3218
3219 let cfg = Config::from_files().unwrap();
3220 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
3221 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
3222 }
3223}