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 #[serde(default = "default_true")]
455 pub setup_wizard: bool,
456}
457
458fn default_true() -> bool {
459 true
460}
461
462fn default_bind() -> std::net::IpAddr {
463 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
464}
465
466impl Default for GraphqlConfig {
467 fn default() -> Self {
468 Self {
469 enabled: true,
470 port: 4000,
471 bind: default_bind(),
472 playground: false,
473 auth_enabled: true,
474 access_token_ttl: "15m".into(),
475 refresh_token_ttl: "30d".into(),
476 cors_origins: Vec::new(),
477 allowed_hosts: Vec::new(),
478 cookie_secure: false,
479 proxy_auth_header: String::new(),
480 proxy_auth_from: Vec::new(),
481 allow_organize: false,
482 setup_wizard: true,
483 }
484 }
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(default)]
496pub struct SubsonicConfig {
497 pub enabled: bool,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub port: Option<u16>,
505 pub username: String,
507 #[serde(default, skip_serializing_if = "String::is_empty")]
510 pub password: String,
511 pub transcode: bool,
514 pub ffmpeg: String,
517}
518
519impl Default for SubsonicConfig {
520 fn default() -> Self {
521 Self {
522 enabled: false,
523 port: None,
524 username: "koan".into(),
525 password: String::new(),
526 transcode: true,
527 ffmpeg: "ffmpeg".into(),
528 }
529 }
530}
531
532#[derive(Debug, Clone, Default, Serialize, Deserialize)]
537#[serde(default)]
538pub struct AuthConfig {
539 #[serde(skip_serializing_if = "String::is_empty")]
541 pub server: String,
542 #[serde(skip_serializing_if = "String::is_empty")]
545 pub refresh_token: String,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(default)]
553pub struct DevicesConfig {
554 pub nearby: bool,
560 pub discoverable: bool,
563 pub port: u16,
566 pub addresses: Vec<String>,
569 pub nearby_control: NearbyControl,
572 pub refused: Vec<String>,
575 pub keep_running: bool,
579}
580
581#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
583#[serde(rename_all = "snake_case")]
584pub enum NearbyControl {
585 #[default]
588 Full,
589 Playback,
592}
593
594impl Default for DevicesConfig {
595 fn default() -> Self {
596 Self {
597 nearby: true,
598 discoverable: true,
599 port: DEVICES_PORT,
600 addresses: Vec::new(),
601 nearby_control: NearbyControl::Full,
602 refused: Vec::new(),
603 keep_running: false,
604 }
605 }
606}
607
608pub const DEVICES_PORT: u16 = 5626;
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
616#[serde(default)]
617pub struct DspConfig {
618 pub enabled: bool,
620 pub profiles: Vec<DspProfile>,
621 #[serde(skip_serializing_if = "Vec::is_empty")]
624 pub autoeq_dismissed: Vec<String>,
625 #[serde(skip_serializing_if = "Vec::is_empty")]
629 pub tunings: Vec<DspOutputTuning>,
630 #[serde(skip_serializing_if = "Vec::is_empty")]
633 pub presets: Vec<DspOutputPreset>,
634 #[serde(skip_serializing_if = "Vec::is_empty")]
637 pub filters_only: Vec<String>,
638 #[serde(skip_serializing_if = "Vec::is_empty")]
643 pub removed: Vec<String>,
644}
645
646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct DspOutputTuning {
649 pub device: String,
650 pub tuning: String,
651 #[serde(default = "on", skip_serializing_if = "is_on")]
653 pub on: bool,
654}
655
656fn on() -> bool {
657 true
658}
659
660fn is_on(on: &bool) -> bool {
661 *on
662}
663
664#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
666pub struct DspOutputPreset {
667 pub device: String,
668 pub preset: String,
669}
670
671impl Default for DspConfig {
672 fn default() -> Self {
673 Self {
674 enabled: true,
675 profiles: Vec::new(),
676 autoeq_dismissed: Vec::new(),
677 tunings: Vec::new(),
678 presets: Vec::new(),
679 filters_only: Vec::new(),
680 removed: Vec::new(),
681 }
682 }
683}
684
685impl DspProfile {
686 pub fn measured(&self) -> Option<&DspMeasurement> {
689 self.fitted.as_ref().or(self.measurement.as_ref())
690 }
691}
692
693impl DspConfig {
694 pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
698 if !self.enabled {
699 return None;
700 }
701 let only = self.filters_only.iter().any(|d| d == device);
702 self.profiles
703 .iter()
704 .find(|p| p.devices.iter().any(|d| d == device))
705 .filter(|p| {
706 !only || !crate::audio::dsp::profiles::shown_role(p, &self.profiles).corrects()
707 })
708 }
709}
710
711#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
712#[serde(default)]
713pub struct DspProfile {
714 pub name: String,
715 pub devices: Vec<String>,
717 #[serde(skip_serializing_if = "Option::is_none")]
720 pub preamp_db: Option<f64>,
721 pub filters: Vec<DspFilter>,
723 pub impulses: Vec<PathBuf>,
727 #[serde(default, skip_serializing_if = "Vec::is_empty")]
730 pub source: Vec<String>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub target: Option<DspTarget>,
735 #[serde(default, skip_serializing_if = "Vec::is_empty")]
739 pub layers: Vec<DspLayer>,
740 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
745 pub group: bool,
746 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
750 pub preset: bool,
751 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
754 pub filters_only: bool,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub scope: Option<DspScope>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub uid: Option<String>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub origin: Option<String>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub role: Option<DspRole>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub measurement: Option<DspMeasurement>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub fitted: Option<DspMeasurement>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub tuned_for: Option<String>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub original: Option<DspOriginal>,
795}
796
797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
799pub struct DspOriginal {
800 pub filters: Vec<DspFilter>,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub preamp_db: Option<f64>,
803}
804
805#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(rename_all = "snake_case")]
809pub enum DspRole {
810 Correction,
813 Tuning,
815 Baked,
820}
821
822impl DspRole {
823 pub fn corrects(self) -> bool {
825 matches!(self, Self::Correction | Self::Baked)
826 }
827}
828
829#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
831pub struct DspMeasurement {
832 pub ear: DspEar,
833 pub target: String,
835}
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
838#[serde(rename_all = "snake_case")]
839pub enum DspEar {
840 In,
841 Over,
842 Speaker,
843}
844
845pub mod dsp_bounds {
850 use std::ops::RangeInclusive;
851 pub const FREQ_HZ: RangeInclusive<f64> = 1.0..=48_000.0;
852 pub const GAIN_DB: RangeInclusive<f64> = -30.0..=30.0;
853 pub const Q: RangeInclusive<f64> = 0.01..=100.0;
854 pub const DELAY_MS: RangeInclusive<f64> = 0.0..=2_000.0;
855 pub const DELAY_SAMPLES: RangeInclusive<f64> = 0.0..=768_000.0;
857 pub const MIX_GAIN: f64 = 31.63;
859 pub const CHANNELS: u16 = 64;
860 pub const BANDS_PER_CHANNEL: usize = 64;
862 pub const FILTERS: usize = 256;
863 pub const GRAPHIC_POINTS: usize = 2_048;
864 pub const LAYERS: usize = 32;
865 pub const NAME: usize = 128;
866
867 pub const CHAIN_DELAY_MS: f64 = 2_000.0;
875 pub const CHAIN_GRAPHICS: usize = 2;
879 pub const CHAIN_MIXES: usize = 8;
881 pub const IMPULSE_TAPS: usize = 262_145;
885}
886
887pub fn budget(filters: &mut Vec<DspFilter>) -> Vec<String> {
891 use dsp_bounds as b;
892 let mut notes = Vec::new();
893 let mut note = |n: String| {
894 if !notes.contains(&n) {
895 notes.push(n);
896 }
897 };
898 let channels = b::CHANNELS as usize;
899 let mut delay = vec![0.0f64; channels];
900 let mut graphics = vec![0usize; channels];
901 let mut mixes = 0;
902 let on = |cs: &[u16], c: usize| cs.is_empty() || cs.contains(&(c as u16));
903 filters.retain_mut(|f| match f {
904 DspFilter::Delay(d) => {
905 let ms = d.ms + d.samples / 44.1;
906 let room = (0..channels)
907 .filter(|c| on(&d.channels, *c))
908 .map(|c| b::CHAIN_DELAY_MS - delay[c])
909 .fold(b::CHAIN_DELAY_MS, f64::min)
910 .max(0.0);
911 if ms > room {
912 note(format!(
913 "delays shortened to {} ms in all",
914 b::CHAIN_DELAY_MS
915 ));
916 if room <= 0.0 {
917 return false;
918 }
919 let scale = room / ms;
921 d.ms *= scale;
922 d.samples = (d.samples * scale).floor();
923 }
924 let ms = d.ms + d.samples / 44.1;
925 for (c, total) in delay.iter_mut().enumerate() {
926 if on(&d.channels, c) {
927 *total += ms;
928 }
929 }
930 true
931 }
932 DspFilter::Graphic(g) => {
933 let full =
934 (0..channels).any(|c| on(&g.channels, c) && graphics[c] == b::CHAIN_GRAPHICS);
935 if full {
936 note(format!(
937 "graphic EQs past {} a channel dropped",
938 b::CHAIN_GRAPHICS
939 ));
940 return false;
941 }
942 for (c, n) in graphics.iter_mut().enumerate() {
943 if on(&g.channels, c) {
944 *n += 1;
945 }
946 }
947 true
948 }
949 DspFilter::Mix(_) => {
950 mixes += 1;
951 if mixes > b::CHAIN_MIXES {
952 note(format!("mixes past {} dropped", b::CHAIN_MIXES));
953 return false;
954 }
955 true
956 }
957 DspFilter::Band(_) => true,
958 });
959 notes
960}
961
962impl DspProfile {
963 pub fn sanitize(&mut self) -> Vec<String> {
971 use dsp_bounds as b;
972 let mut notes: Vec<String> = Vec::new();
973 let mut note = |n: String| {
974 if !notes.contains(&n) {
975 notes.push(n);
976 }
977 };
978 let mut clamp = |what: &str, v: &mut f64, r: &std::ops::RangeInclusive<f64>, unit: &str| {
979 let c = v.clamp(*r.start(), *r.end());
980 if c != *v {
981 note(format!(
982 "{what} clamped to {}{unit}",
983 if *v > *r.end() { r.end() } else { r.start() }
984 ));
985 *v = c;
986 }
987 };
988 let finite = |v: f64| v.is_finite();
989 let mut dropped = Vec::new();
990
991 let cleaned: String = self
992 .name
993 .chars()
994 .filter(|c| !c.is_control())
995 .take(b::NAME)
996 .collect();
997 let cleaned = if cleaned.trim().is_empty() {
998 "Profile".to_owned()
999 } else {
1000 cleaned
1001 };
1002 if cleaned != self.name {
1003 dropped.push("name shortened".to_owned());
1004 self.name = cleaned;
1005 }
1006 if let Some(db) = self.preamp_db.as_mut() {
1007 if finite(*db) {
1008 clamp("preamp", db, &b::GAIN_DB, " dB");
1009 } else {
1010 self.preamp_db = None;
1011 dropped.push("preamp dropped".to_owned());
1012 }
1013 }
1014 let bad_channel = |cs: &[u16]| cs.iter().any(|c| *c >= b::CHANNELS);
1015 let mut per_channel = [0usize; b::CHANNELS as usize];
1016 let before = self.filters.len();
1017 let mut kept = Vec::with_capacity(before.min(b::FILTERS));
1018 for mut filter in std::mem::take(&mut self.filters) {
1019 if kept.len() == b::FILTERS {
1020 dropped.push(format!("filters past {} dropped", b::FILTERS));
1021 break;
1022 }
1023 let keep = match &mut filter {
1024 DspFilter::Band(f) => {
1025 if ![f.freq, f.gain_db, f.q].into_iter().all(finite) || bad_channel(&f.channels)
1026 {
1027 false
1028 } else {
1029 let on =
1030 |c: usize| f.channels.is_empty() || f.channels.contains(&(c as u16));
1031 if (0..b::CHANNELS as usize)
1032 .any(|c| on(c) && per_channel[c] == b::BANDS_PER_CHANNEL)
1033 {
1034 dropped.push(format!(
1035 "bands past {} a channel dropped",
1036 b::BANDS_PER_CHANNEL
1037 ));
1038 continue;
1039 } else {
1040 for (c, n) in per_channel.iter_mut().enumerate() {
1041 if on(c) {
1042 *n += 1;
1043 }
1044 }
1045 clamp("a band's frequency", &mut f.freq, &b::FREQ_HZ, " Hz");
1046 clamp("a band's gain", &mut f.gain_db, &b::GAIN_DB, " dB");
1047 clamp("a band's Q", &mut f.q, &b::Q, "");
1048 true
1049 }
1050 }
1051 }
1052 DspFilter::Delay(d) => {
1053 if !finite(d.ms) || !finite(d.samples) || bad_channel(&d.channels) {
1054 false
1055 } else {
1056 clamp("delay", &mut d.ms, &b::DELAY_MS, " ms");
1057 clamp("delay", &mut d.samples, &b::DELAY_SAMPLES, " samples");
1058 true
1059 }
1060 }
1061 DspFilter::Mix(m) => {
1062 m.outputs.truncate(b::CHANNELS as usize);
1063 for o in &mut m.outputs {
1064 o.retain(|(c, g)| *c < b::CHANNELS && finite(*g));
1065 o.truncate(b::CHANNELS as usize);
1066 for (_, g) in o.iter_mut() {
1067 clamp("a mix's gain", g, &(-b::MIX_GAIN..=b::MIX_GAIN), "");
1068 }
1069 }
1070 true
1071 }
1072 DspFilter::Graphic(g) => {
1073 if bad_channel(&g.channels) {
1074 false
1075 } else {
1076 g.points.retain(|(hz, db)| finite(*hz) && finite(*db));
1077 if g.points.len() > b::GRAPHIC_POINTS {
1078 g.points.truncate(b::GRAPHIC_POINTS);
1079 dropped.push(format!(
1080 "graphic EQ points past {} dropped",
1081 b::GRAPHIC_POINTS
1082 ));
1083 }
1084 for (hz, db) in &mut g.points {
1085 clamp(
1086 "a graphic EQ's frequency",
1087 hz,
1088 &(0.0..=*b::FREQ_HZ.end()),
1089 " Hz",
1090 );
1091 clamp("a graphic EQ's gain", db, &b::GAIN_DB, " dB");
1092 }
1093 true
1094 }
1095 }
1096 };
1097 if keep {
1098 kept.push(filter);
1099 } else {
1100 dropped.push("a filter that could not play dropped".to_owned());
1101 }
1102 }
1103 self.filters = kept;
1104 for n in budget(&mut self.filters) {
1105 dropped.push(n);
1106 }
1107 if self.layers.len() > b::LAYERS {
1108 self.layers.truncate(b::LAYERS);
1109 dropped.push(format!("layers past {} dropped", b::LAYERS));
1110 }
1111 self.layers.retain(|l| l.profile.chars().count() <= b::NAME);
1112 if self
1113 .origin
1114 .as_ref()
1115 .is_some_and(|o| o.chars().count() > b::NAME)
1116 {
1117 self.origin = None;
1118 }
1119 if self
1120 .tuned_for
1121 .as_ref()
1122 .is_some_and(|t| t.chars().count() > b::NAME)
1123 {
1124 self.tuned_for = None;
1125 dropped.push("tuned-for target dropped".to_owned());
1126 }
1127 if self
1128 .measurement
1129 .as_ref()
1130 .is_some_and(|m| m.target.chars().count() > b::NAME)
1131 {
1132 self.measurement = None;
1133 dropped.push("measurement's target dropped".to_owned());
1134 }
1135 if self
1136 .fitted
1137 .as_ref()
1138 .is_some_and(|m| m.target.chars().count() > b::NAME)
1139 {
1140 self.fitted = None;
1141 dropped.push("measurement's target dropped".to_owned());
1142 }
1143 if let Some(t) = &mut self.target {
1144 let long = |s: &str| s.chars().count() > b::NAME;
1145 if long(&t.made_for) {
1146 self.target = None;
1147 dropped.push("target dropped".to_owned());
1148 } else if t.chosen.as_deref().is_some_and(long) {
1149 t.chosen = None;
1150 dropped.push("chosen target dropped".to_owned());
1151 }
1152 }
1153 if let Some(o) = self.original.take() {
1155 let mut kept = DspProfile {
1156 filters: o.filters,
1157 preamp_db: o.preamp_db,
1158 ..Default::default()
1159 };
1160 kept.sanitize();
1161 self.original = Some(DspOriginal {
1162 filters: kept.filters,
1163 preamp_db: kept.preamp_db,
1164 });
1165 }
1166 for d in dropped {
1167 note(d);
1168 }
1169 notes
1170 }
1171
1172 pub fn sanitized(&self) -> Self {
1174 let mut p = self.clone();
1175 p.sanitize();
1176 p
1177 }
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1182#[serde(rename_all = "snake_case")]
1183pub enum DspScope {
1184 Everywhere,
1186 Device,
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1192pub struct DspLayer {
1193 pub profile: String,
1194 #[serde(default = "layer_on")]
1195 pub on: bool,
1196}
1197
1198fn layer_on() -> bool {
1199 true
1200}
1201
1202#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1204pub struct DspTarget {
1205 pub made_for: String,
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub chosen: Option<String>,
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1216#[serde(tag = "type", rename_all = "snake_case")]
1217pub enum DspFilter {
1218 Delay(Delay),
1219 Mix(Mix),
1220 Graphic(GraphicEq),
1221 #[serde(untagged)]
1222 Band(EqFilter),
1223}
1224
1225impl DspFilter {
1226 pub fn channels(&self) -> &[u16] {
1229 match self {
1230 Self::Band(f) => &f.channels,
1231 Self::Delay(d) => &d.channels,
1232 Self::Graphic(g) => &g.channels,
1233 Self::Mix(_) => &[],
1234 }
1235 }
1236
1237 pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
1238 match self {
1239 Self::Band(f) => Some(&mut f.channels),
1240 Self::Delay(d) => Some(&mut d.channels),
1241 Self::Graphic(g) => Some(&mut g.channels),
1242 Self::Mix(_) => None,
1243 }
1244 }
1245}
1246
1247impl From<EqFilter> for DspFilter {
1248 fn from(f: EqFilter) -> Self {
1249 Self::Band(f)
1250 }
1251}
1252
1253#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1255#[serde(default)]
1256pub struct Delay {
1257 #[serde(skip_serializing_if = "is_zero")]
1258 pub ms: f64,
1259 #[serde(skip_serializing_if = "is_zero")]
1260 pub samples: f64,
1261 #[serde(skip_serializing_if = "std::ops::Not::not")]
1264 pub subsample: bool,
1265 #[serde(skip_serializing_if = "Vec::is_empty")]
1266 pub channels: Vec<u16>,
1267}
1268
1269fn is_zero(v: &f64) -> bool {
1270 *v == 0.0
1271}
1272
1273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1277pub struct Mix {
1278 pub outputs: Vec<Vec<(u16, f64)>>,
1279}
1280
1281#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1285pub struct GraphicEq {
1286 pub points: Vec<(f64, f64)>,
1287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1288 pub channels: Vec<u16>,
1289}
1290
1291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1293pub struct EqFilter {
1294 #[serde(rename = "type")]
1295 pub kind: EqFilterKind,
1296 pub freq: f64,
1298 #[serde(default)]
1299 pub gain_db: f64,
1300 #[serde(default = "default_q")]
1301 pub q: f64,
1302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1304 pub channels: Vec<u16>,
1305}
1306
1307fn default_q() -> f64 {
1308 std::f64::consts::FRAC_1_SQRT_2
1309}
1310
1311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1312#[serde(rename_all = "snake_case")]
1313pub enum EqFilterKind {
1314 Peaking,
1315 LowShelf,
1316 HighShelf,
1317 LowPass,
1318 HighPass,
1319 Notch,
1320 BandPass,
1321 AllPass,
1322 LowShelfFirstOrder,
1325 HighShelfFirstOrder,
1326 LowPassFirstOrder,
1327 HighPassFirstOrder,
1328 AllPassFirstOrder,
1329 Gain,
1331}
1332
1333impl EqFilterKind {
1334 pub fn name(self) -> &'static str {
1336 match self {
1337 Self::Peaking => "peaking",
1338 Self::LowShelf => "low_shelf",
1339 Self::HighShelf => "high_shelf",
1340 Self::LowPass => "low_pass",
1341 Self::HighPass => "high_pass",
1342 Self::Notch => "notch",
1343 Self::BandPass => "band_pass",
1344 Self::AllPass => "all_pass",
1345 Self::LowShelfFirstOrder => "low_shelf_first_order",
1346 Self::HighShelfFirstOrder => "high_shelf_first_order",
1347 Self::LowPassFirstOrder => "low_pass_first_order",
1348 Self::HighPassFirstOrder => "high_pass_first_order",
1349 Self::AllPassFirstOrder => "all_pass_first_order",
1350 Self::Gain => "gain",
1351 }
1352 }
1353}
1354
1355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1357pub enum Layer {
1358 Shared,
1360 Machine,
1362}
1363
1364pub fn layer_of(path: &str) -> Layer {
1374 match path {
1375 "remote.password"
1377 | "remote.api_key"
1378 | "remote.device_key"
1379 | "subsonic.password"
1380 | "auth.refresh_token"
1381 | "push.key"
1382 | "push.key_path"
1383 | "push.key_id"
1384 | "push.team_id"
1385 | "library.folders"
1387 | "remote.enabled"
1388 | "remote.url"
1389 | "remote.username"
1390 | "remote.cache_dir"
1391 | "remote.cache_limit"
1392 | "remote.play_queue"
1394 | "playback.output_device"
1396 | "playback.renderer"
1397 | "playback.renderer_name"
1398 | "playback.renderers"
1399 | "playback.muted"
1400 | "playback.rate_switch_lead_in_ms"
1401 | "subsonic.enabled"
1404 | "subsonic.port"
1405 | "subsonic.username"
1406 | "subsonic.transcode"
1407 | "subsonic.ffmpeg"
1408 | "devices.nearby"
1410 | "devices.discoverable"
1411 | "devices.port"
1412 | "devices.addresses"
1413 | "devices.nearby_control"
1414 | "devices.refused"
1415 | "devices.keep_running"
1416 | "auth.server"
1418 | "playback.art_size"
1420 | "visualizer.enabled"
1421 | "visualizer.mode"
1422 | "visualizer.matrix_overlay"
1423 | "visualizer.bass_shake"
1424 | "appearance.rainbow"
1425 | "appearance.text_size" => Layer::Machine,
1427 p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
1429 _ => Layer::Shared,
1430 }
1431}
1432
1433type ConfigStamp = (PathBuf, Option<SystemTime>, Option<SystemTime>);
1440
1441type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
1442
1443static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
1444 LazyLock::new(|| parking_lot::RwLock::new(None));
1445
1446static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1451
1452fn config_stamp() -> ConfigStamp {
1453 let (base, local) = stamp_of(&config_file_path(), &config_local_file_path());
1454 (config_dir(), base, local)
1455}
1456
1457fn stamp_of(base: &Path, local: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
1459 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
1460 (mtime(base), mtime(local))
1461}
1462
1463impl Config {
1464 fn figment() -> Figment {
1470 let base_path = config_file_path();
1471 let local_path = config_local_file_path();
1472
1473 Figment::from(Serialized::defaults(Config::default()))
1474 .merge(Toml::file(&base_path))
1475 .merge(Toml::file(&local_path))
1476 .merge(Env::prefixed("KOAN_").split("__"))
1477 }
1478
1479 pub fn load() -> Result<Self, ConfigError> {
1481 let cfg: Self = Self::figment()
1482 .extract()
1483 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
1484
1485 check_secrets_in_git();
1488
1489 Ok(cfg)
1490 }
1491
1492 pub fn load_or_default() -> Self {
1497 (*Self::cached()).clone()
1498 }
1499
1500 pub fn cached() -> Arc<Config> {
1507 let generation = CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst);
1508 let stamp = config_stamp();
1509 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
1510 && *seen == stamp
1511 {
1512 return cfg.clone();
1513 }
1514
1515 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
1516 log::warn!("failed to load config, using defaults: {}", e);
1517 Self::default()
1518 }));
1519 let mut cache = CONFIG_CACHE.write();
1524 if CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == generation
1525 && config_stamp() == stamp
1526 {
1527 *cache = Some((stamp, cfg.clone()));
1528 }
1529 cfg
1530 }
1531
1532 pub fn invalidate_cache() {
1536 let mut cache = CONFIG_CACHE.write();
1537 CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1538 *cache = None;
1539 }
1540
1541 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1543 let contents = fs::read_to_string(path)?;
1544 let config: Config = toml::from_str(&contents)?;
1545 Ok(config)
1546 }
1547
1548 fn from_files() -> Result<Self, ConfigError> {
1554 Figment::from(Serialized::defaults(Config::default()))
1555 .merge(Toml::file(config_file_path()))
1556 .merge(Toml::file(config_local_file_path()))
1557 .extract()
1558 .map_err(|e| ConfigError::Figment(Box::new(e)))
1559 }
1560
1561 pub fn set_in_files(path: &str) -> bool {
1564 [config_file_path(), config_local_file_path()]
1565 .iter()
1566 .any(|file| {
1567 let Ok(doc) = read_document(file) else {
1568 return true;
1570 };
1571 let mut item = doc.as_item();
1572 for key in path.split('.') {
1573 match item.get(key) {
1574 Some(next) => item = next,
1575 None => return false,
1576 }
1577 }
1578 true
1579 })
1580 }
1581
1582 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
1594 where
1595 F: FnOnce(&mut Config),
1596 {
1597 static ONE_AT_A_TIME: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
1601 let _one = ONE_AT_A_TIME.lock();
1602 let before = Self::from_files()?;
1603 let mut after = before.clone();
1604 mutate(&mut after);
1605
1606 let mut changes = Vec::new();
1607 diff_into(
1608 "",
1609 &toml::Value::try_from(&before)?,
1610 &toml::Value::try_from(&after)?,
1611 &mut changes,
1612 );
1613 if changes.is_empty() {
1614 return Ok(());
1615 }
1616
1617 let base_path = config_file_path();
1618 let local_path = config_local_file_path();
1619 let mut base = read_document(&base_path)?;
1620 let mut local = read_document(&local_path)?;
1621
1622 for (path, value) in &changes {
1623 let (target, other) = match layer_of(path) {
1624 Layer::Shared => (&mut base, &mut local),
1625 Layer::Machine => (&mut local, &mut base),
1626 };
1627 match value {
1628 Some(v) => doc_set(target, path, v),
1629 None => doc_remove(target, path),
1630 }
1631 doc_remove(other, path);
1632 }
1633
1634 write_document(&base_path, &base, false)?;
1635 write_document(&local_path, &local, true)?;
1636 Self::invalidate_cache();
1637 Ok(())
1638 }
1639
1640 pub fn cache_dir(&self) -> PathBuf {
1642 self.remote
1643 .cache_dir
1644 .clone()
1645 .unwrap_or_else(default_cache_dir)
1646 }
1647
1648 pub fn cache_limit_bytes(&self) -> Option<u64> {
1650 self.remote
1651 .cache_limit
1652 .as_deref()
1653 .and_then(parse_size_bytes)
1654 }
1655}
1656
1657fn diff_into(
1662 prefix: &str,
1663 before: &toml::Value,
1664 after: &toml::Value,
1665 out: &mut Vec<(String, Option<toml::Value>)>,
1666) {
1667 let (b, a) = match (before.as_table(), after.as_table()) {
1668 (Some(b), Some(a)) => (b, a),
1669 _ => {
1670 if before != after {
1671 out.push((prefix.to_string(), Some(after.clone())));
1672 }
1673 return;
1674 }
1675 };
1676
1677 let empty = toml::Value::Table(toml::map::Map::new());
1678 for key in b
1679 .keys()
1680 .chain(a.keys())
1681 .collect::<std::collections::BTreeSet<_>>()
1682 {
1683 let path = if prefix.is_empty() {
1684 key.clone()
1685 } else {
1686 format!("{prefix}.{key}")
1687 };
1688 match (b.get(key), a.get(key)) {
1689 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
1690 (None, Some(av)) => diff_into(&path, &empty, av, out),
1693 (Some(_), None) => out.push((path, None)),
1694 (None, None) => unreachable!("key came from one of the two tables"),
1695 }
1696 }
1697}
1698
1699fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
1700 let Ok(contents) = fs::read_to_string(path) else {
1701 return Ok(toml_edit::DocumentMut::new());
1702 };
1703 contents
1704 .parse::<toml_edit::DocumentMut>()
1705 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
1706}
1707
1708fn write_document(
1711 path: &Path,
1712 doc: &toml_edit::DocumentMut,
1713 secret: bool,
1714) -> Result<(), ConfigError> {
1715 let contents = doc.to_string();
1716 if contents.trim().is_empty() && !path.exists() {
1717 return Ok(());
1718 }
1719 if let Some(parent) = path.parent() {
1720 fs::create_dir_all(parent)?;
1721 }
1722 let target = link_target(path);
1725 let temp = unique_beside(&target);
1726 let written = write_new(&temp, &target, contents.as_bytes(), secret)
1727 .and_then(|()| fs::rename(&temp, &target));
1728 if let Err(e) = written {
1729 let _ = fs::remove_file(&temp);
1730 return Err(e.into());
1731 }
1732 #[cfg(target_os = "tvos")]
1733 kept::written(path, contents.as_bytes());
1734 Ok(())
1735}
1736
1737fn link_target(path: &Path) -> PathBuf {
1740 let mut target = path.to_path_buf();
1741 for _ in 0..40 {
1742 let Ok(next) = fs::read_link(&target) else {
1743 break;
1744 };
1745 target = match target.parent() {
1746 Some(parent) if next.is_relative() => parent.join(next),
1747 _ => next,
1748 };
1749 }
1750 target
1751}
1752
1753fn unique_beside(target: &Path) -> PathBuf {
1756 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1757 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1758 let name = target
1759 .file_name()
1760 .map(|n| n.to_string_lossy().into_owned())
1761 .unwrap_or_default();
1762 target.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
1763}
1764
1765fn write_new(temp: &Path, target: &Path, contents: &[u8], secret: bool) -> std::io::Result<()> {
1769 use std::io::Write as _;
1770 let mut options = fs::OpenOptions::new();
1771 options.write(true).create_new(true);
1772 #[cfg(unix)]
1773 let kept_mode = {
1774 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1775 let kept_mode = (!secret)
1776 .then(|| fs::metadata(target).ok())
1777 .flatten()
1778 .map(|m| m.permissions().mode() & 0o7777);
1779 options.mode(if secret {
1780 0o600
1781 } else {
1782 kept_mode.unwrap_or(0o666)
1783 });
1784 kept_mode
1785 };
1786 #[cfg(not(unix))]
1787 let _ = (target, secret);
1788 let mut file = options.open(temp)?;
1789 file.write_all(contents)?;
1790 #[cfg(unix)]
1793 if let Some(mode) = kept_mode {
1794 use std::os::unix::fs::PermissionsExt;
1795 file.set_permissions(fs::Permissions::from_mode(mode))?;
1796 }
1797 Ok(())
1798}
1799
1800fn implicit_table() -> toml_edit::Item {
1801 let mut table = toml_edit::Table::new();
1802 table.set_implicit(true);
1805 toml_edit::Item::Table(table)
1806}
1807
1808fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
1809 let segments: Vec<&str> = path.split('.').collect();
1810 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1811
1812 let mut table = doc.as_table_mut();
1813 for segment in parents {
1814 let item = table.entry(segment).or_insert_with(implicit_table);
1815 if !item.is_table() {
1818 *item = implicit_table();
1819 }
1820 table = item.as_table_mut().expect("just ensured it is a table");
1821 }
1822 match table.get_mut(last) {
1825 Some(existing) => *existing = to_edit_item(value),
1826 None => {
1827 table.insert(last, to_edit_item(value));
1828 }
1829 }
1830}
1831
1832fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
1835 let toml::Value::Array(items) = value else {
1836 return toml_edit::value(to_edit_value(value));
1837 };
1838 if items.is_empty() || !items.iter().all(toml::Value::is_table) {
1839 return toml_edit::value(to_edit_value(value));
1840 }
1841 let mut sections = toml_edit::ArrayOfTables::new();
1842 for item in items {
1843 let mut section = toml_edit::Table::new();
1844 for (k, v) in item.as_table().expect("checked above") {
1845 let mut v = to_edit_value(v);
1846 if let toml_edit::Value::Array(list) = &mut v
1847 && list.iter().any(toml_edit::Value::is_inline_table)
1848 {
1849 for entry in list.iter_mut() {
1850 entry.decor_mut().set_prefix("\n ");
1851 if let Some(t) = entry.as_inline_table_mut() {
1852 t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1853 }
1854 }
1855 list.set_trailing("\n");
1856 list.set_trailing_comma(true);
1857 }
1858 section.insert(k, toml_edit::value(v));
1859 }
1860 section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1861 sections.push(section);
1862 }
1863 toml_edit::Item::ArrayOfTables(sections)
1864}
1865
1866fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
1868 let k = key.get();
1869 (if k == "name" || k == "type" { 0 } else { 1 }, k)
1870}
1871
1872fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
1873 let segments: Vec<&str> = path.split('.').collect();
1874 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1875
1876 let mut table = doc.as_table_mut();
1877 for segment in parents {
1878 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
1879 Some(child) => table = child,
1880 None => return,
1881 }
1882 }
1883 table.remove(last);
1886}
1887
1888fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
1889 match value {
1890 toml::Value::String(s) => s.as_str().into(),
1891 toml::Value::Integer(i) => (*i).into(),
1892 toml::Value::Float(f) => (*f).into(),
1893 toml::Value::Boolean(b) => (*b).into(),
1894 toml::Value::Datetime(d) => d.to_string().into(),
1895 toml::Value::Array(items) => items
1896 .iter()
1897 .map(to_edit_value)
1898 .collect::<toml_edit::Array>()
1899 .into(),
1900 toml::Value::Table(t) => {
1901 let mut inline = toml_edit::InlineTable::new();
1902 for (k, v) in t {
1903 inline.insert(k, to_edit_value(v));
1904 }
1905 inline.into()
1906 }
1907 }
1908}
1909
1910pub fn config_dir() -> PathBuf {
1919 if let Some(dir) = CONFIG_DIR.read().clone() {
1920 return dir;
1921 }
1922 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
1923 return PathBuf::from(dir);
1924 }
1925 platform_config_dir()
1926}
1927
1928#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
1929fn platform_config_dir() -> PathBuf {
1930 dirs::home_dir()
1931 .unwrap_or_else(|| PathBuf::from("."))
1932 .join(".config")
1933 .join("koan")
1934}
1935
1936#[cfg(target_os = "ios")]
1940fn platform_config_dir() -> PathBuf {
1941 ios_library().join("Application Support").join("koan")
1942}
1943
1944#[cfg(target_os = "tvos")]
1949fn platform_config_dir() -> PathBuf {
1950 let dir = ios_library().join("Caches").join("koan-config");
1951 kept::restore(&dir);
1952 dir
1953}
1954
1955#[cfg(target_os = "tvos")]
1964mod kept {
1965 use std::fs;
1966 use std::os::unix::fs::PermissionsExt;
1967 use std::path::Path;
1968 use std::sync::Once;
1969
1970 use core_foundation::base::TCFType;
1971 use core_foundation::data::{CFData, CFDataRef};
1972 use core_foundation::string::CFString;
1973 use core_foundation_sys::base::{CFGetTypeID, CFRelease};
1974 use core_foundation_sys::data::CFDataGetTypeID;
1975 use core_foundation_sys::preferences::{
1976 CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesSetAppValue,
1977 kCFPreferencesCurrentApplication,
1978 };
1979
1980 const FILES: [&str; 2] = ["config.toml", "config.local.toml"];
1981
1982 pub(super) fn restore(dir: &Path) {
1986 static ONCE: Once = Once::new();
1987 ONCE.call_once(|| {
1988 for name in FILES {
1989 let path = dir.join(name);
1990 match fs::read(&path) {
1991 Ok(contents) => save(name, &contents),
1992 Err(_) => {
1993 let Some(contents) = load(name) else { continue };
1994 let _ = fs::create_dir_all(dir);
1995 if fs::write(&path, &contents).is_ok() {
1996 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
1997 log::info!("config: restored {name} after the system cleared it");
1998 }
1999 }
2000 }
2001 }
2002 });
2003 }
2004
2005 pub(super) fn written(path: &Path, contents: &[u8]) {
2008 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2009 return;
2010 };
2011 if FILES.contains(&name) && path.parent() == Some(super::platform_config_dir().as_path()) {
2012 save(name, contents);
2013 }
2014 }
2015
2016 fn save(name: &str, contents: &[u8]) {
2017 let key = CFString::new(name);
2018 let data = CFData::from_buffer(contents);
2019 unsafe {
2022 CFPreferencesSetAppValue(
2023 key.as_concrete_TypeRef(),
2024 data.as_CFTypeRef(),
2025 kCFPreferencesCurrentApplication,
2026 );
2027 CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
2028 }
2029 }
2030
2031 fn load(name: &str) -> Option<Vec<u8>> {
2032 let key = CFString::new(name);
2033 unsafe {
2036 let value = CFPreferencesCopyAppValue(
2037 key.as_concrete_TypeRef(),
2038 kCFPreferencesCurrentApplication,
2039 );
2040 if value.is_null() {
2041 return None;
2042 }
2043 if CFGetTypeID(value) != CFDataGetTypeID() {
2044 CFRelease(value);
2045 return None;
2046 }
2047 Some(
2048 CFData::wrap_under_create_rule(value as CFDataRef)
2049 .bytes()
2050 .to_vec(),
2051 )
2052 }
2053 }
2054}
2055
2056#[cfg(any(target_os = "ios", target_os = "tvos"))]
2057fn ios_library() -> PathBuf {
2058 dirs::home_dir()
2059 .unwrap_or_else(|| PathBuf::from("."))
2060 .join("Library")
2061}
2062
2063fn default_cache_dir() -> PathBuf {
2070 #[cfg(any(target_os = "ios", target_os = "tvos"))]
2071 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
2072 return ios_library().join("Caches").join("koan");
2073 }
2074 config_dir().join("cache")
2075}
2076
2077pub fn set_config_dir(dir: impl Into<PathBuf>) {
2084 *CONFIG_DIR.write() = Some(dir.into());
2085 Config::invalidate_cache();
2086}
2087
2088pub fn isolate_config_for_tests() {
2104 let _one = SWITCHING.lock().unwrap_or_else(|e| e.into_inner());
2105 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
2106 let _ = fs::create_dir_all(&dir);
2107 set_config_dir(dir);
2108}
2109
2110#[doc(hidden)]
2114pub static SWITCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2115
2116static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
2117 LazyLock::new(|| parking_lot::RwLock::new(None));
2118
2119pub fn config_file_path() -> PathBuf {
2121 config_dir().join("config.toml")
2122}
2123
2124pub fn config_local_file_path() -> PathBuf {
2126 config_dir().join("config.local.toml")
2127}
2128
2129pub fn db_path() -> PathBuf {
2131 config_dir().join("koan.db")
2132}
2133
2134const LOG_LIMIT: u64 = 16 * 1024 * 1024;
2137const LOG_CHECK_EVERY: u32 = 4096;
2140
2141#[derive(Default)]
2143pub struct LogFile {
2144 file: Option<fs::File>,
2145 lines: u32,
2146}
2147
2148impl LogFile {
2149 pub fn write(&mut self, line: std::fmt::Arguments) {
2150 use std::io::Write as _;
2151 if self.file.is_none() {
2152 self.file = open_log();
2153 }
2154 let Some(file) = self.file.as_mut() else {
2155 return;
2156 };
2157 let _ = writeln!(file, "{line}");
2158 self.lines = self.lines.wrapping_add(1);
2159 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
2160 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
2161 {
2162 self.file = open_log();
2163 }
2164 }
2165
2166 pub fn flush(&mut self) {
2167 if let Some(file) = self.file.as_mut() {
2168 let _ = std::io::Write::flush(file);
2169 }
2170 }
2171}
2172
2173fn open_log() -> Option<fs::File> {
2179 let dir = config_dir();
2180 fs::create_dir_all(&dir).ok()?;
2181 let path = dir.join("koan.log");
2182 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
2183 let _ = fs::rename(&path, dir.join("koan.log.1"));
2184 }
2185 fs::OpenOptions::new()
2186 .create(true)
2187 .append(true)
2188 .open(path)
2189 .ok()
2190}
2191
2192fn check_secrets_in_git() {
2199 static ONCE: Once = Once::new();
2200 ONCE.call_once(scan_for_tracked_secrets);
2201}
2202
2203fn scan_for_tracked_secrets() {
2204 let sensitive_fields = ["password", "api_key", "refresh_token"];
2205
2206 for (label, path) in [
2207 ("config.toml", config_file_path()),
2208 ("config.local.toml", config_local_file_path()),
2209 ] {
2210 let Ok(contents) = std::fs::read_to_string(&path) else {
2211 continue;
2212 };
2213
2214 let has_secrets = sensitive_fields.iter().any(|field| {
2216 contents.lines().any(|line| {
2217 let line = line.trim();
2218 if let Some(rest) = line.strip_prefix(field) {
2219 let rest = rest.trim_start();
2220 if let Some(value) = rest.strip_prefix('=') {
2221 let value = value.trim().trim_matches('"').trim_matches('\'');
2222 return !value.is_empty();
2223 }
2224 }
2225 false
2226 })
2227 });
2228
2229 if !has_secrets {
2230 continue;
2231 }
2232
2233 if is_tracked_by_git(&path) {
2235 eprintln!();
2236 eprintln!("╔══════════════════════════════════════════════════════════════╗");
2237 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
2238 eprintln!("╠══════════════════════════════════════════════════════════════╣");
2239 eprintln!("║ ║");
2240 eprintln!("║ File: {:<52} ║", path.display());
2241 eprintln!("║ ║");
2242 eprintln!("║ Your password is in version control. You should: ║");
2243 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
2244 eprintln!("║ 2. Add it to .gitignore ║");
2245 eprintln!("║ 3. Rotate your credentials immediately ║");
2246 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
2247 eprintln!("║ `koan remote login` writes there for you ║");
2248 eprintln!("║ ║");
2249 eprintln!("╚══════════════════════════════════════════════════════════════╝");
2250 eprintln!();
2251 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
2252 }
2253 }
2254}
2255
2256fn is_tracked_by_git(path: &Path) -> bool {
2258 let Some(parent) = path.parent() else {
2259 return false;
2260 };
2261 std::process::Command::new("git")
2263 .args(["ls-files", "--error-unmatch"])
2264 .arg(path)
2265 .current_dir(parent)
2266 .stdout(std::process::Stdio::null())
2267 .stderr(std::process::Stdio::null())
2268 .status()
2269 .is_ok_and(|s| s.success())
2270}
2271
2272#[cfg(test)]
2273pub(crate) mod tests {
2274 use super::*;
2275 use std::fs;
2276
2277 fn tmp_dir() -> PathBuf {
2278 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
2279 fs::create_dir_all(&dir).unwrap();
2280 dir
2281 }
2282
2283 #[test]
2284 fn test_defaults() {
2285 let cfg = Config::default();
2286 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2287 assert!(!cfg.remote.enabled);
2288 }
2289
2290 #[test]
2291 fn test_roundtrip_toml() {
2292 let cfg = Config::default();
2293 let serialized = toml::to_string_pretty(&cfg).unwrap();
2294 let deserialized: Config = toml::from_str(&serialized).unwrap();
2295 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
2296 assert_eq!(
2297 deserialized.remote.download_workers,
2298 cfg.remote.download_workers
2299 );
2300 }
2301
2302 #[test]
2303 fn test_load_from_file() {
2304 let dir = tempfile::tempdir().unwrap();
2305 let path = dir.path().join("config.toml");
2306 fs::write(
2307 &path,
2308 r#"
2309[library]
2310folders = ["/tmp/music"]
2311
2312[playback]
2313replaygain = "track"
2314"#,
2315 )
2316 .unwrap();
2317
2318 let cfg = Config::load_from(&path).unwrap();
2319 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
2320 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
2321 assert!(!cfg.remote.enabled);
2322 }
2323
2324 #[test]
2325 fn test_partial_toml_uses_defaults() {
2326 let dir = tempfile::tempdir().unwrap();
2327 let path = dir.path().join("partial.toml");
2328 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
2329
2330 let cfg = Config::load_from(&path).unwrap();
2331 assert_eq!(cfg.playback.target_fps, 30);
2332 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2333 }
2334
2335 #[test]
2336 fn test_figment_layered_loading() {
2337 let dir = tempfile::tempdir().unwrap();
2338 let base_path = dir.path().join("config.toml");
2339 let local_path = dir.path().join("config.local.toml");
2340
2341 fs::write(
2342 &base_path,
2343 r#"
2344[remote]
2345url = "https://base.example.com"
2346"#,
2347 )
2348 .unwrap();
2349 fs::write(
2350 &local_path,
2351 r#"
2352[remote]
2353enabled = true
2354url = "https://local.example.com"
2355username = "admin"
2356password = "secret"
2357"#,
2358 )
2359 .unwrap();
2360
2361 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2363 .merge(Toml::file(&base_path))
2364 .merge(Toml::file(&local_path))
2365 .extract()
2366 .unwrap();
2367
2368 assert!(cfg.remote.enabled);
2369 assert_eq!(cfg.remote.url, "https://local.example.com");
2370 assert_eq!(cfg.remote.username, "admin");
2371 assert_eq!(cfg.remote.password, "secret");
2372 }
2373
2374 #[test]
2375 fn test_figment_missing_keys_preserved() {
2376 let dir = tempfile::tempdir().unwrap();
2377 let base_path = dir.path().join("config.toml");
2378 let local_path = dir.path().join("config.local.toml");
2379
2380 fs::write(
2381 &base_path,
2382 r#"
2383[remote]
2384url = "https://keep.me"
2385username = "keepuser"
2386"#,
2387 )
2388 .unwrap();
2389 fs::write(
2390 &local_path,
2391 r#"
2392[remote]
2393password = "secret"
2394"#,
2395 )
2396 .unwrap();
2397
2398 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2399 .merge(Toml::file(&base_path))
2400 .merge(Toml::file(&local_path))
2401 .extract()
2402 .unwrap();
2403
2404 assert_eq!(cfg.remote.url, "https://keep.me");
2405 assert_eq!(cfg.remote.username, "keepuser");
2406 assert_eq!(cfg.remote.password, "secret");
2407 }
2408
2409 #[test]
2410 fn test_env_var_override() {
2411 let dir = tempfile::tempdir().unwrap();
2412 let base_path = dir.path().join("config.toml");
2413
2414 fs::write(
2415 &base_path,
2416 r#"
2417[remote]
2418url = "https://file.example.com"
2419"#,
2420 )
2421 .unwrap();
2422
2423 unsafe {
2425 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
2426 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
2427 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
2428 }
2429
2430 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2431 .merge(Toml::file(&base_path))
2432 .merge(Env::prefixed("KOAN_").split("__"))
2433 .extract()
2434 .unwrap();
2435
2436 assert_eq!(cfg.remote.url, "https://env.example.com");
2437 assert_eq!(cfg.remote.password, "env-secret");
2438 assert_eq!(cfg.graphql.port, 9999);
2439
2440 unsafe {
2442 std::env::remove_var("KOAN_REMOTE__URL");
2443 std::env::remove_var("KOAN_REMOTE__PASSWORD");
2444 std::env::remove_var("KOAN_GRAPHQL__PORT");
2445 }
2446 }
2447
2448 #[test]
2449 fn test_cache_dir_default() {
2450 let cfg = Config::default();
2451 assert!(cfg.cache_dir().ends_with("cache"));
2452 }
2453
2454 #[test]
2455 fn test_cache_dir_explicit() {
2456 let mut cfg = Config::default();
2457 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
2458 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
2459 }
2460
2461 #[test]
2462 fn test_organize_config_defaults() {
2463 let cfg = Config::default();
2464 assert!(cfg.organize.default.is_none());
2465 assert!(cfg.organize.patterns.is_empty());
2466 }
2467
2468 #[test]
2469 fn test_organize_config_from_toml() {
2470 let dir = tmp_dir();
2471 let path = dir.join("organize.toml");
2472 fs::write(
2473 &path,
2474 r#"
2475[organize]
2476default = "standard"
2477
2478[organize.patterns]
2479standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
2480va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
2481"#,
2482 )
2483 .unwrap();
2484
2485 let cfg = Config::load_from(&path).unwrap();
2486 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
2487 assert_eq!(cfg.organize.patterns.len(), 2);
2488 assert!(cfg.organize.patterns.contains_key("standard"));
2489 assert!(cfg.organize.patterns.contains_key("va-aware"));
2490
2491 fs::remove_dir_all(&dir).ok();
2492 }
2493
2494 #[test]
2495 fn test_figment_organize_patterns_merge() {
2496 let dir = tempfile::tempdir().unwrap();
2497 let base_path = dir.path().join("config.toml");
2498 let local_path = dir.path().join("config.local.toml");
2499
2500 fs::write(
2501 &base_path,
2502 r#"
2503[organize]
2504default = "standard"
2505
2506[organize.patterns]
2507standard = "base-pattern"
2508"#,
2509 )
2510 .unwrap();
2511 fs::write(
2512 &local_path,
2513 r#"
2514[organize]
2515default = "custom"
2516
2517[organize.patterns]
2518custom = "local-pattern"
2519"#,
2520 )
2521 .unwrap();
2522
2523 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2524 .merge(Toml::file(&base_path))
2525 .merge(Toml::file(&local_path))
2526 .extract()
2527 .unwrap();
2528
2529 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
2531 assert_eq!(cfg.organize.patterns.len(), 2);
2533 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
2534 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
2535 }
2536
2537 #[test]
2538 fn test_output_device_config_roundtrip() {
2539 let mut cfg = Config::default();
2540 cfg.playback.output_device = Some("My DAC".into());
2541
2542 let serialized = toml::to_string_pretty(&cfg).unwrap();
2543 let deserialized: Config = toml::from_str(&serialized).unwrap();
2544 assert_eq!(
2545 deserialized.playback.output_device.as_deref(),
2546 Some("My DAC")
2547 );
2548 }
2549
2550 #[test]
2551 fn test_output_device_config_default_is_none() {
2552 let cfg = Config::default();
2553 assert!(cfg.playback.output_device.is_none());
2554
2555 let serialized = toml::to_string_pretty(&cfg).unwrap();
2557 assert!(!serialized.contains("output_device"));
2558 let deserialized: Config = toml::from_str(&serialized).unwrap();
2559 assert!(deserialized.playback.output_device.is_none());
2560 }
2561
2562 #[test]
2563 fn test_output_device_config_from_toml() {
2564 let dir = tempfile::tempdir().unwrap();
2565 let path = dir.path().join("config.toml");
2566 fs::write(
2567 &path,
2568 r#"
2569[playback]
2570output_device = "External Speakers"
2571"#,
2572 )
2573 .unwrap();
2574
2575 let cfg = Config::load_from(&path).unwrap();
2576 assert_eq!(
2577 cfg.playback.output_device.as_deref(),
2578 Some("External Speakers")
2579 );
2580 }
2581
2582 #[test]
2583 fn test_graphql_bind_defaults_to_localhost() {
2584 let cfg = GraphqlConfig::default();
2585 assert_eq!(
2586 cfg.bind,
2587 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2588 );
2589 }
2590
2591 #[test]
2592 fn test_graphql_bind_from_toml() {
2593 let toml_str = r#"
2594[graphql]
2595bind = "0.0.0.0"
2596port = 5000
2597"#;
2598 let cfg: Config = toml::from_str(toml_str).unwrap();
2599 assert_eq!(
2600 cfg.graphql.bind,
2601 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
2602 );
2603 assert_eq!(cfg.graphql.port, 5000);
2604 }
2605
2606 #[test]
2607 fn test_graphql_bind_omitted_defaults_to_localhost() {
2608 let toml_str = r#"
2609[graphql]
2610port = 4000
2611"#;
2612 let cfg: Config = toml::from_str(toml_str).unwrap();
2613 assert_eq!(
2614 cfg.graphql.bind,
2615 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2616 );
2617 }
2618
2619 #[test]
2620 fn test_organize_config_roundtrip() {
2621 let mut cfg = Config::default();
2622 cfg.organize.default = Some("standard".into());
2623 cfg.organize
2624 .patterns
2625 .insert("standard".into(), "%artist%/%title%".into());
2626
2627 let serialized = toml::to_string_pretty(&cfg).unwrap();
2628 let deserialized: Config = toml::from_str(&serialized).unwrap();
2629 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
2630 assert_eq!(
2631 deserialized.organize.patterns["standard"],
2632 "%artist%/%title%"
2633 );
2634 }
2635
2636 #[test]
2637 fn test_parse_size_bytes() {
2638 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
2639 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
2640 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
2641 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
2642 assert_eq!(parse_size_bytes("1024B"), Some(1024));
2643 assert_eq!(parse_size_bytes("1024"), Some(1024));
2644
2645 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
2647 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
2648
2649 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
2651 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
2652
2653 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
2655 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
2656
2657 assert_eq!(
2659 parse_size_bytes("1.5GB"),
2660 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
2661 );
2662
2663 assert_eq!(parse_size_bytes(""), None);
2665 assert_eq!(parse_size_bytes("abc"), None);
2666 assert_eq!(parse_size_bytes("50XB"), None);
2667 }
2668
2669 #[test]
2670 fn test_cache_limit_config_from_toml() {
2671 let toml_str = r#"
2672[remote]
2673cache_limit = "50GB"
2674"#;
2675 let cfg: Config = toml::from_str(toml_str).unwrap();
2676 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
2677 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
2678 }
2679
2680 #[test]
2681 fn test_cache_limit_none_by_default() {
2682 let cfg = Config::default();
2683 assert!(cfg.remote.cache_limit.is_none());
2684 assert!(cfg.cache_limit_bytes().is_none());
2685 }
2686
2687 #[test]
2688 fn test_cache_limit_not_serialized_when_none() {
2689 let cfg = Config::default();
2690 let serialized = toml::to_string_pretty(&cfg).unwrap();
2691 assert!(!serialized.contains("cache_limit"));
2692 }
2693
2694 #[test]
2695 fn player_uses_config_on_init() {
2696 let dir = tempfile::tempdir().unwrap();
2700 let path = dir.path().join("config.toml");
2701 fs::write(
2702 &path,
2703 r#"
2704[playback]
2705replaygain = "track"
2706output_device = "My Fancy DAC"
2707pre_amp_db = -3.5
2708target_fps = 30
2709art_size = 32
2710
2711[visualizer]
2712enabled = false
2713mode = "oscilloscope"
2714fps = 30
2715"#,
2716 )
2717 .unwrap();
2718
2719 let cfg = Config::load_from(&path).unwrap();
2720
2721 assert_eq!(
2723 cfg.playback.replaygain,
2724 ReplayGainMode::Track,
2725 "replaygain should be 'track'"
2726 );
2727 assert_eq!(
2728 cfg.playback.output_device.as_deref(),
2729 Some("My Fancy DAC"),
2730 "output_device should match config"
2731 );
2732 assert!(
2733 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
2734 "pre_amp_db should be -3.5"
2735 );
2736 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
2737 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
2738
2739 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
2741 assert_eq!(cfg.visualizer.mode, "oscilloscope");
2742 assert_eq!(cfg.visualizer.fps, 30);
2743 }
2744
2745 #[test]
2746 fn a_missing_config_file_stamps_as_absent() {
2747 let dir = tempfile::tempdir().unwrap();
2748 let base = dir.path().join("config.toml");
2749 let local = dir.path().join("config.local.toml");
2750
2751 assert_eq!(stamp_of(&base, &local), (None, None));
2752
2753 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
2754 let (base_stamp, local_stamp) = stamp_of(&base, &local);
2755 assert!(base_stamp.is_some(), "creating the file must be a change");
2756 assert!(local_stamp.is_none());
2757 }
2758
2759 #[test]
2760 fn editing_a_config_file_changes_its_stamp() {
2761 let dir = tempfile::tempdir().unwrap();
2762 let base = dir.path().join("config.toml");
2763 let local = dir.path().join("config.local.toml");
2764 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
2765
2766 let before = stamp_of(&base, &local);
2767 std::thread::sleep(std::time::Duration::from_millis(20));
2769 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
2770
2771 assert_ne!(
2772 before,
2773 stamp_of(&base, &local),
2774 "a config edited by hand has to be picked up"
2775 );
2776 }
2777
2778 #[test]
2779 fn invalidating_forces_a_reload() {
2780 let first = Config::cached();
2781 Config::invalidate_cache();
2782 assert!(
2783 !Arc::ptr_eq(&first, &Config::cached()),
2784 "koan's own writes invalidate explicitly; the next read must re-parse"
2785 );
2786 }
2787
2788 pub(crate) static PERSIST_LOCK: &std::sync::Mutex<()> = &super::SWITCHING;
2793
2794 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
2796 let dir =
2797 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
2798 let _ = fs::remove_dir_all(&dir);
2799 fs::create_dir_all(&dir).unwrap();
2800 set_config_dir(&dir);
2801 (config_file_path(), config_local_file_path())
2802 }
2803
2804 #[cfg(unix)]
2808 #[test]
2809 fn a_write_keeps_links_modes_and_no_leftovers() {
2810 use std::os::unix::fs::PermissionsExt;
2811 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2812 let dir = tempfile::tempdir().unwrap();
2813 let dotfiles = tempfile::tempdir().unwrap();
2814 std::os::unix::fs::symlink(
2815 dotfiles.path().join("config.toml"),
2816 dir.path().join("config.toml"),
2817 )
2818 .unwrap();
2819 set_config_dir(dir.path());
2820
2821 Config::persist(|c| c.graphql.port = 4100).unwrap();
2822 assert!(
2823 fs::symlink_metadata(config_file_path())
2824 .unwrap()
2825 .is_symlink()
2826 );
2827 assert!(
2828 fs::read_to_string(dotfiles.path().join("config.toml"))
2829 .unwrap()
2830 .contains("4100")
2831 );
2832
2833 fs::set_permissions(
2834 dotfiles.path().join("config.toml"),
2835 fs::Permissions::from_mode(0o640),
2836 )
2837 .unwrap();
2838 Config::persist(|c| {
2839 c.graphql.port = 4101;
2840 c.remote.password = "hunter2".into();
2841 })
2842 .unwrap();
2843 let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
2844 assert_eq!(mode(dotfiles.path().join("config.toml")), 0o640);
2845 assert_eq!(mode(config_local_file_path()), 0o600);
2846 assert!(
2847 fs::symlink_metadata(config_file_path())
2848 .unwrap()
2849 .is_symlink()
2850 );
2851
2852 let names = |d: &Path| -> Vec<String> {
2853 fs::read_dir(d)
2854 .unwrap()
2855 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2856 .collect()
2857 };
2858 assert!(!names(dir.path()).iter().any(|n| n.ends_with(".tmp")));
2859 assert_eq!(names(dotfiles.path()), vec!["config.toml".to_string()]);
2860 }
2861
2862 #[test]
2866 fn a_read_during_switches_is_never_kept_for_the_wrong_directory() {
2867 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2868 let dirs: Vec<tempfile::TempDir> = (0..2).map(|_| tempfile::tempdir().unwrap()).collect();
2869 for (dir, port) in dirs.iter().zip([4001, 4002]) {
2870 fs::write(
2871 dir.path().join("config.toml"),
2872 format!("[graphql]\nport = {port}\n"),
2873 )
2874 .unwrap();
2875 }
2876 let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
2877 let reader = std::thread::spawn({
2878 let done = done.clone();
2879 move || {
2880 while !done.load(std::sync::atomic::Ordering::Relaxed) {
2881 Config::cached();
2882 }
2883 }
2884 });
2885 for i in 0..2000 {
2886 let (dir, port) = if i % 2 == 0 {
2887 (&dirs[0], 4001)
2888 } else {
2889 (&dirs[1], 4002)
2890 };
2891 set_config_dir(dir.path());
2892 for _ in 0..3 {
2893 assert_eq!(Config::cached().graphql.port, port, "switch {i}");
2894 }
2895 }
2896 done.store(true, std::sync::atomic::Ordering::Relaxed);
2897 reader.join().unwrap();
2898 }
2899
2900 #[test]
2901 fn persist_keeps_comments_and_untouched_keys() {
2902 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2903 let (base, _local) = persist_sandbox("comments");
2904 fs::write(
2905 &base,
2906 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
2907 )
2908 .unwrap();
2909
2910 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
2911
2912 let written = fs::read_to_string(&base).unwrap();
2913 assert!(
2914 written.contains("# koan — shareable defaults"),
2915 "the header comment must survive a write: {written}"
2916 );
2917 assert!(
2918 written.contains("# fps = 60"),
2919 "commented-out defaults are the template's whole point: {written}"
2920 );
2921 assert!(written.contains("palette = \"neon\""));
2922 assert!(
2923 !written.contains("[graphql]"),
2924 "an untouched section must not be invented: {written}"
2925 );
2926 }
2927
2928 #[test]
2929 fn persist_routes_machine_settings_to_the_local_file() {
2930 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2931 let (base, local) = persist_sandbox("routing");
2932
2933 Config::persist(|cfg| {
2934 cfg.playback.replaygain = ReplayGainMode::Album;
2935 cfg.playback.output_device = Some("My DAC".into());
2936 cfg.playback.art_size = 40;
2937 cfg.visualizer.mode = "starfield".into();
2938 })
2939 .unwrap();
2940
2941 let shared = fs::read_to_string(&base).unwrap();
2942 let machine = fs::read_to_string(&local).unwrap();
2943
2944 assert!(shared.contains("replaygain = \"album\""), "{shared}");
2945 for machine_only in ["output_device", "art_size", "starfield"] {
2946 assert!(
2947 !shared.contains(machine_only),
2948 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
2949 );
2950 assert!(machine.contains(machine_only), "{machine}");
2951 }
2952 }
2953
2954 #[test]
2955 fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
2956 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2957 let (base, local) = persist_sandbox("dsp");
2958
2959 let profile = DspProfile {
2960 name: "HD 600".into(),
2961 devices: vec!["Topping E30".into()],
2962 preamp_db: None,
2963 filters: vec![
2964 DspFilter::Band(EqFilter {
2965 kind: EqFilterKind::Peaking,
2966 freq: 20.0,
2967 gain_db: -1.3,
2968 q: 2.0,
2969 channels: vec![],
2970 }),
2971 DspFilter::Band(EqFilter {
2972 kind: EqFilterKind::HighShelfFirstOrder,
2973 freq: 10000.0,
2974 gain_db: 2.5,
2975 q: 0.7,
2976 channels: vec![],
2977 }),
2978 DspFilter::Delay(Delay {
2979 ms: 1.5,
2980 channels: vec![1],
2981 ..Default::default()
2982 }),
2983 DspFilter::Mix(Mix {
2984 outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
2985 }),
2986 DspFilter::Graphic(GraphicEq {
2987 points: vec![(20.0, -1.0), (1000.0, 0.0)],
2988 channels: vec![],
2989 }),
2990 ],
2991 impulses: vec![],
2992 source: vec![],
2993 target: None,
2994 layers: vec![],
2995 group: false,
2996 preset: false,
2997 filters_only: false,
2998 scope: None,
2999 uid: None,
3000 origin: None,
3001 role: None,
3002 measurement: None,
3003 fitted: None,
3004 tuned_for: None,
3005 original: None,
3006 };
3007 Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
3008
3009 assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
3010 let machine = fs::read_to_string(&local).unwrap();
3011 assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
3012 assert!(
3013 machine.contains("\n { type = \"peaking\""),
3014 "one band per line: {machine}"
3015 );
3016 assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
3017 assert_eq!(
3018 Config::from_files()
3019 .unwrap()
3020 .dsp
3021 .profile_for("Topping E30")
3022 .map(|p| p.name.as_str()),
3023 Some("HD 600")
3024 );
3025 }
3026
3027 #[test]
3030 fn a_profile_is_played_within_bounds() {
3031 let band = |freq: f64, gain_db: f64, q: f64| {
3032 DspFilter::Band(EqFilter {
3033 kind: EqFilterKind::Peaking,
3034 freq,
3035 gain_db,
3036 q,
3037 channels: vec![],
3038 })
3039 };
3040 let mut p = DspProfile {
3041 name: "Loud\u{7}".into(),
3042 preamp_db: Some(60.0),
3043 filters: vec![
3044 DspFilter::Delay(Delay {
3045 ms: 1e12,
3046 ..Default::default()
3047 }),
3048 band(0.5, 200.0, 0.0),
3049 band(f64::NAN, 0.0, 1.0),
3050 DspFilter::Band(EqFilter {
3051 kind: EqFilterKind::Peaking,
3052 freq: 1000.0,
3053 gain_db: 0.0,
3054 q: 1.0,
3055 channels: vec![99],
3056 }),
3057 DspFilter::Mix(Mix {
3058 outputs: vec![vec![(0, 1e9), (1, f64::INFINITY)]],
3059 }),
3060 DspFilter::Graphic(GraphicEq {
3061 points: (0..3000).map(|i| (i as f64, 1.0)).collect(),
3062 channels: vec![],
3063 }),
3064 ],
3065 ..Default::default()
3066 };
3067 p.filters.extend((0..300).map(|_| band(1000.0, 1.0, 1.0)));
3068 let notes = p.sanitize();
3069 let has = |n: &str| notes.iter().any(|x| x == n);
3070 assert!(has("delay clamped to 2000 ms"), "{notes:?}");
3071 assert!(has("preamp clamped to 30 dB"), "{notes:?}");
3072 assert!(has("a band's gain clamped to 30 dB"), "{notes:?}");
3073 assert!(has("a band's frequency clamped to 1 Hz"), "{notes:?}");
3074 assert!(has("a filter that could not play dropped"), "{notes:?}");
3075 assert!(has("bands past 64 a channel dropped"), "{notes:?}");
3076 assert!(has("graphic EQ points past 2048 dropped"), "{notes:?}");
3077 assert_eq!(p.name, "Loud");
3078 assert_eq!(p.preamp_db, Some(30.0));
3079 assert_eq!(
3080 p.filters[0],
3081 DspFilter::Delay(Delay {
3082 ms: 2000.0,
3083 ..Default::default()
3084 })
3085 );
3086 assert_eq!(p.filters[1], band(1.0, 30.0, 0.01));
3087 let DspFilter::Mix(m) = &p.filters[2] else {
3088 panic!("{:?}", p.filters[2])
3089 };
3090 assert_eq!(m.outputs, vec![vec![(0, dsp_bounds::MIX_GAIN)]]);
3091 let bands = p
3092 .filters
3093 .iter()
3094 .filter(|f| matches!(f, DspFilter::Band(_)))
3095 .count();
3096 assert_eq!(bands, dsp_bounds::BANDS_PER_CHANNEL);
3097 assert!(p.clone().sanitize().is_empty(), "within bounds now");
3098 }
3099
3100 #[test]
3101 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
3102 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3103 let (base, _local) = persist_sandbox("folders");
3104
3105 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
3108
3109 let shared = fs::read_to_string(&base).unwrap_or_default();
3110 assert!(
3111 !shared.contains("folders"),
3112 "a visualiser toggle must not invent library folders: {shared}"
3113 );
3114 }
3115
3116 #[test]
3117 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
3118 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3119 let (base, local) = persist_sandbox("drain");
3120 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
3121
3122 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
3123
3124 let shared = fs::read_to_string(&base).unwrap();
3125 assert!(
3126 !shared.contains("art_size"),
3127 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
3128 );
3129 assert!(shared.contains("target_fps"), "{shared}");
3130 assert!(
3131 fs::read_to_string(&local)
3132 .unwrap()
3133 .contains("art_size = 48")
3134 );
3135 }
3136
3137 #[test]
3138 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
3139 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3140 let (_base, local) = persist_sandbox("shadow");
3141 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
3142
3143 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
3144
3145 assert_eq!(
3146 Config::from_files().unwrap().playback.target_fps,
3147 120,
3148 "local wins the merge, so a shared write over a local copy would \
3149 otherwise be silently ignored: {}",
3150 fs::read_to_string(&local).unwrap()
3151 );
3152 }
3153
3154 #[test]
3155 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
3156 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3157 let (base, _local) = persist_sandbox("noop");
3158 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
3159
3160 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
3161
3162 assert_eq!(
3163 fs::read_to_string(&base).unwrap(),
3164 "# untouched\n[playback]\ntarget_fps = 60\n"
3165 );
3166 }
3167
3168 #[test]
3169 fn persist_keeps_passwords_out_of_the_shared_file() {
3170 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3171 let (base, local) = persist_sandbox("secrets");
3172
3173 Config::persist(|cfg| {
3174 cfg.remote.password = "hunter2".into();
3175 cfg.subsonic.password = "s3cret".into();
3176 cfg.visualizer.palette = "mono".into();
3177 })
3178 .unwrap();
3179
3180 let shared = fs::read_to_string(&base).unwrap();
3181 assert!(!shared.contains("hunter2"), "{shared}");
3182 assert!(!shared.contains("s3cret"), "{shared}");
3183 assert!(shared.contains("mono"));
3184
3185 let machine = fs::read_to_string(&local).unwrap();
3186 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
3187 }
3188
3189 #[test]
3190 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
3191 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3192 let (_base, local) = persist_sandbox("clear-secret");
3193 fs::write(
3194 &local,
3195 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
3196 )
3197 .unwrap();
3198
3199 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
3200
3201 let machine = fs::read_to_string(&local).unwrap();
3202 assert!(
3203 !machine.contains("password"),
3204 "an emptied secret should leave no key behind: {machine}"
3205 );
3206 assert!(machine.contains("url"), "{machine}");
3207 }
3208
3209 #[test]
3210 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
3211 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3212 let (base, _local) = persist_sandbox("patterns");
3213 fs::write(
3214 &base,
3215 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
3216 )
3217 .unwrap();
3218
3219 Config::persist(|cfg| {
3220 cfg.organize
3221 .patterns
3222 .insert("standard".into(), "%album artist%/%album%".into());
3223 })
3224 .unwrap();
3225
3226 let cfg = Config::from_files().unwrap();
3227 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
3228 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
3229 }
3230}