1use std::cell::Cell;
16use std::collections::{HashMap, HashSet};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use parking_lot::Mutex;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::config::{self, Config, DspProfile, DspScope};
25use crate::db::connection::Database;
26use crate::db::queries::dsp as rows;
27use crate::remote::client::{KoanDspProfiles, KoanDspSaved, SubsonicClient, SubsonicError};
28
29pub const MAX_FILE: u64 = 32 << 20;
32pub const MAX_ACCOUNT: u64 = 256 << 20;
34pub const MAX_DOC: usize = 512 << 10;
36pub const MAX_FILES: usize = 64;
38pub const MAX_PROFILES: i64 = 1024;
40pub const MAX_DISMISSED: i64 = 1024;
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct SyncDoc {
48 pub profile: DspProfile,
49 pub files: Vec<SyncFile>,
50 #[serde(skip)]
53 pub unknown: serde_json::Map<String, serde_json::Value>,
54}
55
56#[derive(Deserialize)]
58struct Wire {
59 profile: WireProfile,
60 files: Vec<SyncFile>,
61}
62
63#[derive(Deserialize)]
64struct WireProfile {
65 #[serde(flatten)]
66 known: DspProfile,
67 #[serde(flatten)]
68 unknown: serde_json::Map<String, serde_json::Value>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SyncFile {
73 pub name: String,
74 pub sha256: String,
75 pub size: u64,
76}
77
78impl SyncDoc {
79 pub fn parse(json: &str) -> Result<Self, String> {
85 if json.len() > MAX_DOC {
86 return Err(format!("An EQ may hold at most {} KB", MAX_DOC >> 10));
87 }
88 let wire: Wire = serde_json::from_str(json).map_err(|e| format!("Not an EQ: {e}"))?;
89 let mut doc = Self {
90 profile: wire.profile.known,
91 files: wire.files,
92 unknown: wire.profile.unknown,
93 };
94 doc.profile.sanitize();
95 doc.profile.devices.clear();
96 doc.profile.scope = None;
97 doc.profile.uid = None;
98 doc.profile.source.clear();
99 doc.check()?;
100 Ok(doc)
101 }
102
103 pub fn check(&self) -> Result<(), String> {
107 if self.profile.name.trim().is_empty() {
108 return Err("It needs a name".into());
109 }
110 if self.files.len() > MAX_FILES {
111 return Err(format!("An EQ may hold at most {MAX_FILES} files"));
112 }
113 let mut names = HashSet::new();
114 for f in &self.files {
115 if !safe_name(&f.name) {
116 return Err(format!("{:?} is not a file name an EQ may hold", f.name));
117 }
118 if !names.insert(f.name.as_str()) {
119 return Err(format!("{} is named twice", f.name));
120 }
121 if !is_sha256(&f.sha256) {
122 return Err(format!("{} has no SHA-256", f.name));
123 }
124 if f.size > MAX_FILE {
125 return Err(too_large(&f.name));
126 }
127 }
128 if self.size() > MAX_ACCOUNT {
129 return Err(format!(
130 "Its files come to more than the {} MB an account keeps",
131 MAX_ACCOUNT >> 20
132 ));
133 }
134 for ir in &self.profile.impulses {
135 let named = ir.to_str().is_some_and(|n| names.contains(n));
136 if !named {
137 return Err(format!("{} is not among its files", ir.display()));
138 }
139 }
140 Ok(())
141 }
142
143 pub fn size(&self) -> u64 {
145 let mut seen = HashSet::new();
146 self.files
147 .iter()
148 .filter(|f| seen.insert(&f.sha256))
149 .map(|f| f.size)
150 .sum()
151 }
152
153 pub fn json(&self) -> String {
156 let mut value = serde_json::to_value(self).expect("a profile serialises");
157 if let Some(profile) = value.get_mut("profile").and_then(|p| p.as_object_mut()) {
158 for (key, field) in &self.unknown {
159 profile.entry(key.clone()).or_insert_with(|| field.clone());
160 }
161 }
162 value.to_string()
163 }
164
165 pub fn hash(&self) -> String {
170 sha256_hex(
171 serde_json::to_string(self)
172 .expect("a profile serialises")
173 .as_bytes(),
174 )
175 }
176}
177
178fn too_large(name: &str) -> String {
179 format!(
180 "{name} is larger than the {} MB a file may be",
181 MAX_FILE >> 20
182 )
183}
184
185fn safe_name(name: &str) -> bool {
186 !name.is_empty()
187 && name.len() <= 128
188 && !name.starts_with('.')
189 && !name.contains(['/', '\\', '\0'])
190}
191
192pub fn is_sha256(s: &str) -> bool {
193 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
194}
195
196pub fn sha256_hex(bytes: &[u8]) -> String {
197 Sha256::digest(bytes)
198 .iter()
199 .map(|b| format!("{b:02x}"))
200 .collect()
201}
202
203pub fn doc_of(profile: &DspProfile) -> Result<(SyncDoc, HashMap<String, PathBuf>), String> {
207 let base = config::config_dir();
208 let dir = crate::audio::dsp::profiles::dir(&profile.name);
209 if let Some(other) = Config::cached()
212 .dsp
213 .profiles
214 .iter()
215 .find(|p| p.name != profile.name && crate::audio::dsp::profiles::dir(&p.name) == dir)
216 {
217 return Err(format!(
218 "{} shares its folder with {}; rename one of them to sync it",
219 profile.name, other.name
220 ));
221 }
222 let mut files = Vec::new();
223 let mut paths = HashMap::new();
224 if let Ok(entries) = std::fs::read_dir(&dir) {
225 let mut entries: Vec<_> = entries.flatten().collect();
226 entries.sort_by_key(|e| e.file_name());
227 for entry in entries {
228 let name = entry.file_name().to_string_lossy().into_owned();
229 if !safe_name(&name) || !entry.file_type().is_ok_and(|t| t.is_file()) {
230 continue;
231 }
232 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
233 if size > MAX_FILE {
234 return Err(too_large(&name));
235 }
236 let bytes = std::fs::read(entry.path()).map_err(|e| format!("{name}: {e}"))?;
237 let sha256 = sha256_hex(&bytes);
238 paths.insert(sha256.clone(), entry.path());
239 files.push(SyncFile { name, sha256, size });
240 }
241 }
242 let mut travelling = profile.clone();
243 travelling.devices.clear();
244 travelling.scope = None;
245 travelling.uid = None;
246 travelling.source.clear();
247 travelling.origin = profile.origin.clone().or_else(|| Some(this_device()));
248 travelling.impulses = profile
249 .impulses
250 .iter()
251 .map(|path| {
252 let full = if path.is_absolute() {
253 path.clone()
254 } else {
255 base.join(path)
256 };
257 match (full.parent(), full.file_name()) {
258 (Some(parent), Some(name)) if parent == dir => Ok(PathBuf::from(name)),
259 _ => Err(format!(
260 "{} is kept outside {}'s folder, so it cannot be sent",
261 path.display(),
262 profile.name
263 )),
264 }
265 })
266 .collect::<Result<_, _>>()?;
267 let doc = SyncDoc {
268 profile: travelling,
269 files,
270 unknown: Default::default(),
271 };
272 doc.check()?;
273 Ok((doc, paths))
274}
275
276static DEVICE_NAME: parking_lot::RwLock<Option<String>> = parking_lot::RwLock::new(None);
279
280pub fn set_device_name(name: Option<String>) {
281 *DEVICE_NAME.write() = name.filter(|n| !n.trim().is_empty());
282}
283
284fn this_device() -> String {
285 DEVICE_NAME
286 .read()
287 .clone()
288 .unwrap_or_else(|| crate::remote::link::LinkIdentity::this_device(None).name)
289}
290
291pub fn sounds_same(a: &SyncDoc, b: &SyncDoc, member: &dyn Fn(&str) -> Option<SyncDoc>) -> bool {
300 same(a, b, member, 0)
301}
302
303fn same(a: &SyncDoc, b: &SyncDoc, member: &dyn Fn(&str) -> Option<SyncDoc>, depth: usize) -> bool {
304 use crate::config::DspFilter;
305 let (pa, pb) = (&a.profile, &b.profile);
306 let close =
307 |x: f64, y: f64, abs: f64, rel: f64| (x - y).abs() <= abs.max(rel * x.abs().max(y.abs()));
308 let db = |x: f64, y: f64| close(x, y, 0.05, 0.0);
309 let preamp = match (pa.preamp_db, pb.preamp_db) {
310 (Some(x), Some(y)) => db(x, y),
311 (None, None) => true,
312 _ => false,
313 };
314 if !preamp
317 || pa.target != pb.target
318 || pa.measurement != pb.measurement
319 || pa.fitted != pb.fitted
320 || pa.group != pb.group
321 || pa.tuned_for != pb.tuned_for
322 {
323 return false;
324 }
325 let files = |d: &SyncDoc| {
326 let mut h: Vec<String> = d.files.iter().map(|f| f.sha256.clone()).collect();
327 h.sort_unstable();
328 h
329 };
330 if files(a) != files(b) {
331 return false;
332 }
333 let named = |d: &SyncDoc| -> Vec<String> {
334 d.profile
335 .impulses
336 .iter()
337 .filter_map(|ir| {
338 d.files
339 .iter()
340 .find(|f| Some(f.name.as_str()) == ir.to_str())
341 })
342 .map(|f| f.sha256.clone())
343 .collect()
344 };
345 if named(a) != named(b) {
346 return false;
347 }
348 let runs = |filters: &[DspFilter]| -> Vec<Vec<DspFilter>> {
351 let mut out = vec![Vec::new()];
352 for f in filters {
353 if matches!(f, DspFilter::Band(_)) {
354 out.last_mut().expect("one at least").push(f.clone());
355 } else {
356 out.push(vec![f.clone()]);
357 out.push(Vec::new());
358 }
359 }
360 for run in &mut out {
361 run.sort_by(|x, y| match (x, y) {
362 (DspFilter::Band(x), DspFilter::Band(y)) => (&x.channels, x.freq)
363 .partial_cmp(&(&y.channels, y.freq))
364 .unwrap_or(std::cmp::Ordering::Equal),
365 _ => std::cmp::Ordering::Equal,
366 });
367 }
368 out.retain(|r| !r.is_empty());
369 out
370 };
371 let alike = |x: &DspFilter, y: &DspFilter| match (x, y) {
372 (DspFilter::Band(x), DspFilter::Band(y)) => {
373 x.kind == y.kind
374 && x.channels == y.channels
375 && close(x.freq, y.freq, 0.0, 0.001)
376 && db(x.gain_db, y.gain_db)
377 && close(x.q, y.q, 0.0, 0.005)
378 }
379 (DspFilter::Delay(x), DspFilter::Delay(y)) => {
380 x.channels == y.channels
381 && close(x.ms, y.ms, 0.001, 0.001)
382 && close(x.samples, y.samples, 0.5, 0.0)
383 && x.subsample == y.subsample
384 }
385 (DspFilter::Mix(x), DspFilter::Mix(y)) => {
386 x.outputs.len() == y.outputs.len()
387 && x.outputs.iter().zip(&y.outputs).all(|(p, q)| {
388 p.len() == q.len()
389 && p.iter()
390 .zip(q)
391 .all(|((c, g), (d, h))| c == d && close(*g, *h, 0.001, 0.005))
392 })
393 }
394 (DspFilter::Graphic(x), DspFilter::Graphic(y)) => {
395 x.channels == y.channels
396 && x.points.len() == y.points.len()
397 && x.points
398 .iter()
399 .zip(&y.points)
400 .all(|((f, g), (e, h))| close(*f, *e, 0.0, 0.001) && db(*g, *h))
401 }
402 _ => false,
403 };
404 let (ra, rb) = (runs(&pa.filters), runs(&pb.filters));
405 let filters = ra.len() == rb.len()
406 && ra
407 .iter()
408 .zip(&rb)
409 .all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(f, g)| alike(f, g)));
410 if !filters || pa.layers.len() != pb.layers.len() {
411 return false;
412 }
413 pa.layers.iter().zip(&pb.layers).all(|(x, y)| {
414 x.on == y.on
415 && (x.profile == y.profile
416 || depth < 8
417 && match (member(&x.profile), member(&y.profile)) {
418 (Some(m), Some(n)) => same(&m, &n, member, depth + 1),
419 _ => false,
420 })
421 })
422}
423
424pub trait Remote {
426 fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError>;
427 fn save(&self, uid: &str, edited_at: i64, doc: &str) -> Result<KoanDspSaved, SubsonicError>;
428 fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError>;
429 fn dismiss(&self, output: &str) -> Result<(), SubsonicError>;
430 fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError>;
431 fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError>;
432}
433
434impl Remote for SubsonicClient {
435 fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
436 self.koan_dsp_profiles(since)
437 }
438 fn save(&self, uid: &str, edited_at: i64, doc: &str) -> Result<KoanDspSaved, SubsonicError> {
439 self.koan_dsp_profile_save(uid, edited_at, doc)
440 }
441 fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError> {
442 self.koan_dsp_profile_delete(uid, edited_at)
443 }
444 fn dismiss(&self, output: &str) -> Result<(), SubsonicError> {
445 self.koan_dsp_dismiss(output)
446 }
447 fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
448 self.koan_dsp_file(sha256)
449 }
450 fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
451 self.koan_dsp_upload(sha256, data)
452 }
453}
454
455#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
457pub struct DspSync {
458 pub applied: usize,
460 pub sent: usize,
462}
463
464impl DspSync {
465 pub fn changed(&self) -> bool {
467 self.applied > 0
468 }
469}
470
471static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
473
474thread_local! {
475 static APPLYING: Cell<bool> = const { Cell::new(false) };
478}
479
480pub fn sync(db: &Database) -> DspSync {
483 let cfg = Config::load().unwrap_or_default();
484 let Some(client) = crate::helpers::subsonic_client(&cfg) else {
485 return DspSync::default();
486 };
487 reconcile(db, &client, &cfg.remote.url)
488}
489
490static DEFERRED: AtomicBool = AtomicBool::new(false);
494static PENDING: AtomicBool = AtomicBool::new(false);
495
496pub fn defer() {
499 DEFERRED.store(true, Ordering::Relaxed);
500}
501
502pub fn flush(db: &Database) -> Option<Result<DspSync, String>> {
505 if !PENDING.swap(false, Ordering::Relaxed) {
506 return None;
507 }
508 let cfg = Config::load().unwrap_or_default();
509 let client = crate::helpers::subsonic_client(&cfg)?;
510 let offers = crate::remote::profile::for_auth(client.auth())
511 .is_some_and(|p| p.offers(crate::remote::profile::DSP_PROFILES));
512 if !offers {
513 return Some(Ok(DspSync::default()));
514 }
515 Some(try_run(db, client.as_ref(), &cfg.remote.url).map_err(|e| e.to_string()))
516}
517
518pub fn reconcile(db: &Database, client: &SubsonicClient, url: &str) -> DspSync {
522 let offers = crate::remote::profile::for_auth(client.auth())
523 .is_some_and(|p| p.offers(crate::remote::profile::DSP_PROFILES));
524 if !offers {
525 return DspSync::default();
526 }
527 run(db, client, url)
528}
529
530pub fn run(db: &Database, remote: &dyn Remote, url: &str) -> DspSync {
532 try_run(db, remote, url).unwrap_or_else(|e| {
533 log::warn!("dsp sync: {e}");
534 DspSync::default()
535 })
536}
537
538fn try_run(db: &Database, remote: &dyn Remote, url: &str) -> Result<DspSync, Failed> {
539 let _one = ONE_AT_A_TIME.lock();
540 APPLYING.with(|a| a.set(true));
541 let out = sync_with(db, remote, url);
542 APPLYING.with(|a| a.set(false));
543 if out.as_ref().is_ok_and(DspSync::changed) {
544 crate::signal::engine_changed().bump();
545 }
546 out
547}
548
549static QUEUED: AtomicBool = AtomicBool::new(false);
552
553pub fn changed() {
557 if APPLYING.with(Cell::get) {
558 return;
559 }
560 if DEFERRED.load(Ordering::Relaxed) {
561 PENDING.store(true, Ordering::Relaxed);
562 return;
563 }
564 let cfg = Config::cached();
565 if crate::helpers::subsonic_auth(&cfg).is_none() {
566 return;
567 }
568 if QUEUED.swap(true, Ordering::AcqRel) {
569 return;
570 }
571 let spawned = std::thread::Builder::new()
572 .name("koan-dsp-sync".into())
573 .spawn(|| {
574 drop(ONE_AT_A_TIME.lock());
576 QUEUED.store(false, Ordering::Release);
577 let Ok(db) = crate::db::pool::shared().get() else {
578 return;
579 };
580 sync(&db);
581 });
582 if let Err(e) = spawned {
583 QUEUED.store(false, Ordering::Release);
584 log::warn!("dsp sync: could not start: {e}");
585 }
586}
587
588pub fn note(db: &Database, name: &str) -> Option<String> {
590 let cfg = Config::cached();
591 let uid = cfg
592 .dsp
593 .profiles
594 .iter()
595 .find(|p| p.name == name)?
596 .uid
597 .clone()?;
598 rows::local_edits(&db.conn).ok()?.remove(&uid)?.note
599}
600
601pub fn refusal(db: &Database, name: &str) -> Option<String> {
603 let cfg = Config::cached();
604 let uid = cfg
605 .dsp
606 .profiles
607 .iter()
608 .find(|p| p.name == name)?
609 .uid
610 .clone()?;
611 rows::local_edits(&db.conn).ok()?.remove(&uid)?.refused
612}
613
614#[derive(Debug, thiserror::Error)]
615enum Failed {
616 #[error("{0}")]
617 Db(#[from] crate::db::connection::DbError),
618 #[error("{0}")]
619 Remote(#[from] SubsonicError),
620 #[error("{0}")]
621 Config(#[from] crate::config::ConfigError),
622}
623
624struct Local {
626 doc: SyncDoc,
627 hash: String,
628 paths: HashMap<String, PathBuf>,
629}
630
631fn now_ms() -> i64 {
632 std::time::SystemTime::now()
633 .duration_since(std::time::UNIX_EPOCH)
634 .map_or(0, |d| d.as_millis() as i64)
635}
636
637fn sync_with(db: &Database, remote: &dyn Remote, url: &str) -> Result<DspSync, Failed> {
638 let mut out = DspSync::default();
639 first_sync(db)?;
640 let mut locals = observe(db, url)?;
641 let mut dismissed = pull(db, remote, url, &mut locals, &mut out)?;
642 if push(db, remote, url, &locals, &dismissed, &mut out)? {
643 locals = observe(db, url)?;
645 dismissed = pull(db, remote, url, &mut locals, &mut out)?;
646 }
647 let _ = dismissed;
648 Ok(out)
649}
650
651fn first_sync(db: &Database) -> Result<(), Failed> {
655 let first = rows::local_edits(&db.conn)?.is_empty()
656 && db
657 .conn
658 .query_row("SELECT COUNT(*) FROM dsp_sync_cursor", [], |r| {
659 r.get::<_, i64>(0)
660 })
661 .map_err(crate::db::connection::DbError::from)?
662 == 0;
663 if !first {
664 return Ok(());
665 }
666 let cfg = Config::cached();
667 let pin: Vec<String> = cfg
668 .dsp
669 .profiles
670 .iter()
671 .filter(|p| {
672 p.scope.is_none() && p.target.is_none() && p.measured().is_none() && p.layers.is_empty()
673 })
674 .map(|p| p.name.clone())
675 .collect();
676 if !pin.is_empty() {
677 Config::persist(|c| {
678 for p in c.dsp.profiles.iter_mut().filter(|p| pin.contains(&p.name)) {
679 p.scope = Some(DspScope::Device);
680 }
681 })?;
682 }
683 Ok(())
684}
685
686fn observe(db: &Database, url: &str) -> Result<HashMap<String, Local>, Failed> {
697 use crate::audio::dsp::profiles::scope;
698 let cfg = Config::cached();
699 let all = cfg.dsp.profiles.clone();
700 let synced = rows::synced(&db.conn, url)?;
701 let unpinned: Vec<String> = all
702 .iter()
703 .filter(|p| p.scope.is_none())
704 .filter(|p| {
705 scope(p, &all) == DspScope::Everywhere
706 || p.uid.as_ref().is_some_and(|u| synced.contains_key(u))
707 })
708 .map(|p| p.name.clone())
709 .collect();
710 if !unpinned.is_empty() {
711 Config::persist(|c| {
712 for p in c
713 .dsp
714 .profiles
715 .iter_mut()
716 .filter(|p| unpinned.contains(&p.name))
717 {
718 p.scope = Some(DspScope::Everywhere);
719 }
720 })?;
721 return observe(db, url);
722 }
723 let everywhere: Vec<&DspProfile> = all
724 .iter()
725 .filter(|p| crate::audio::dsp::profiles::scope(p, &all) == DspScope::Everywhere)
726 .collect();
727 let unnamed: Vec<String> = everywhere
728 .iter()
729 .filter(|p| p.uid.is_none())
730 .map(|p| p.name.clone())
731 .collect();
732 if !unnamed.is_empty() {
733 Config::persist(|c| {
734 for p in c
735 .dsp
736 .profiles
737 .iter_mut()
738 .filter(|p| unnamed.contains(&p.name))
739 {
740 p.uid = Some(uuid::Uuid::now_v7().to_string());
741 }
742 })?;
743 return observe(db, url);
744 }
745 let seen = rows::local_edits(&db.conn)?;
746 let mut locals = HashMap::new();
747 for p in everywhere {
748 let uid = p.uid.clone().expect("given one above");
749 match doc_of(p) {
750 Ok((doc, paths)) => {
751 let hash = doc.hash();
752 if seen.get(&uid).is_none_or(|s| s.hash != hash) {
753 rows::set_local_edit(&db.conn, &uid, &hash, now_ms())?;
754 }
755 locals.insert(uid, Local { doc, hash, paths });
756 }
757 Err(why) => {
758 if seen
759 .get(&uid)
760 .is_none_or(|s| s.refused.as_deref() != Some(&why))
761 {
762 rows::set_local_edit(&db.conn, &uid, "", now_ms())?;
763 rows::set_refused(&db.conn, &uid, Some(&why))?;
764 }
765 }
766 }
767 }
768 Ok(locals)
769}
770
771fn pull(
775 db: &Database,
776 remote: &dyn Remote,
777 url: &str,
778 locals: &mut HashMap<String, Local>,
779 out: &mut DspSync,
780) -> Result<HashSet<String>, Failed> {
781 let cursor = rows::sync_cursor(&db.conn, url)?;
782 let page = remote.changes(cursor)?;
783 let mut synced = rows::synced(&db.conn, url)?;
784 let edits = rows::local_edits(&db.conn)?;
785 let mut held = false;
786 for row in &page.profile {
787 if synced.get(&row.uid).is_some_and(|(rev, _)| *rev >= row.rev) {
790 rows::set_sync_cursor(&db.conn, url, row.rev)?;
791 continue;
792 }
793 if let Some(doc) = row.doc.as_deref().and_then(|j| SyncDoc::parse(j).ok()) {
796 let folder = crate::audio::dsp::profiles::dir(&doc.profile.name);
797 let member = |name: &str| {
798 let cfg = Config::cached();
799 cfg.dsp
800 .profiles
801 .iter()
802 .find(|p| p.name == name)
803 .and_then(|p| doc_of(p).ok())
804 .map(|(d, _)| d)
805 };
806 let twin = locals
807 .iter()
808 .find(|(uid, l)| {
809 **uid != row.uid
810 && !synced.contains_key(*uid)
811 && crate::audio::dsp::profiles::dir(&l.doc.profile.name) == folder
812 && sounds_same(&l.doc, &doc, &member)
813 })
814 .map(|(uid, _)| uid.clone());
815 if let Some(twin) = twin {
816 Config::persist(|c| {
817 for p in c
818 .dsp
819 .profiles
820 .iter_mut()
821 .filter(|p| p.uid.as_deref() == Some(&twin))
822 {
823 p.uid = Some(row.uid.clone());
824 }
825 })?;
826 rows::forget_local(&db.conn, &twin)?;
827 let l = locals.remove(&twin).expect("found above");
828 locals.insert(row.uid.clone(), l);
829 }
830 }
831 let local = locals.get(&row.uid);
832 let dirty = local.is_some_and(|l| synced.get(&row.uid).is_none_or(|(_, h)| *h != l.hash));
833 let edited_here = edits.get(&row.uid).map_or(0, |e| e.edited_at);
834 if dirty && edited_here > row.edited_at {
835 } else if let Some(json) = &row.doc {
837 let doc = match SyncDoc::parse(json) {
838 Ok(doc) => doc,
839 Err(e) => {
840 log::warn!("dsp sync: skipping {}: {e}", row.uid);
841 rows::set_sync_cursor(&db.conn, url, row.rev)?;
842 continue;
843 }
844 };
845 let have: HashSet<&str> = local
846 .map(|l| l.paths.keys().map(String::as_str).collect())
847 .unwrap_or_default();
848 let mut fetched = Vec::new();
849 for f in doc
850 .files
851 .iter()
852 .filter(|f| !have.contains(f.sha256.as_str()))
853 {
854 match remote.file(&f.sha256) {
855 Ok(bytes) if sha256_hex(&bytes) == f.sha256 => {
856 fetched.push((f.sha256.clone(), bytes))
857 }
858 other => {
859 log::info!(
860 "dsp sync: {} not fetched yet ({:?}); holding the cursor",
861 f.name,
862 other.err()
863 );
864 held = true;
865 break;
866 }
867 }
868 }
869 if held {
870 break;
871 }
872 let reused: HashMap<String, PathBuf> =
873 local.map(|l| l.paths.clone()).unwrap_or_default();
874 for (renamed, old, new) in adopt(&row.uid, &doc, fetched, &reused, &synced)? {
875 let why = format!(
876 "Renamed from “{old}”: it met a different EQ of that name from another device, so each is named for where it came from"
877 );
878 log::info!("dsp sync: {old} is now {new}");
879 if let Some(uid) = renamed {
880 rows::set_note(&db.conn, &uid, &why)?;
881 }
882 }
883 let hash = doc.hash();
884 rows::set_synced(&db.conn, url, &row.uid, row.rev, &hash)?;
885 rows::set_local_edit(&db.conn, &row.uid, &hash, row.edited_at)?;
886 synced.insert(row.uid.clone(), (row.rev, hash));
887 out.applied += 1;
888 } else {
889 drop_profile(&row.uid)?;
890 rows::forget_synced(&db.conn, url, &row.uid)?;
891 rows::forget_local(&db.conn, &row.uid)?;
892 synced.remove(&row.uid);
893 locals.remove(&row.uid);
894 out.applied += 1;
895 }
896 rows::set_sync_cursor(&db.conn, url, row.rev)?;
897 }
898 if !held {
899 rows::set_sync_cursor(&db.conn, url, page.cursor)?;
900 }
901 let dismissed: HashSet<String> = page.dismissed.into_iter().map(|d| d.output).collect();
902 let cfg = Config::cached();
903 let missing: Vec<String> = dismissed
904 .iter()
905 .filter(|d| !cfg.dsp.autoeq_dismissed.contains(d))
906 .cloned()
907 .collect();
908 if !missing.is_empty() {
909 Config::persist(|c| c.dsp.autoeq_dismissed.extend(missing))?;
910 }
911 if out.applied > 0 {
912 *locals = observe(db, url)?;
913 }
914 Ok(dismissed)
915}
916
917fn push(
921 db: &Database,
922 remote: &dyn Remote,
923 url: &str,
924 locals: &HashMap<String, Local>,
925 dismissed: &HashSet<String>,
926 out: &mut DspSync,
927) -> Result<bool, Failed> {
928 let synced = rows::synced(&db.conn, url)?;
929 let edits = rows::local_edits(&db.conn)?;
930 let cfg = Config::cached();
931 let mut sent = HashSet::new();
935 let mut restore = false;
936 for uid in synced.keys().filter(|u| !locals.contains_key(*u)) {
937 let deleted = cfg.dsp.removed.contains(uid);
938 let kept_here = cfg
939 .dsp
940 .profiles
941 .iter()
942 .any(|p| p.uid.as_deref() == Some(uid) && p.scope == Some(DspScope::Device));
943 if !deleted && !kept_here {
944 if edits.get(uid).is_some_and(|e| e.refused.is_some()) {
945 continue;
946 }
947 log::warn!("dsp sync: {uid} is missing here but was not deleted; restoring it");
953 rows::set_sync_cursor(&db.conn, url, 0)?;
954 rows::forget_synced(&db.conn, url, uid)?;
955 restore = true;
956 continue;
957 }
958 remote.delete(uid, now_ms())?;
959 rows::forget_synced(&db.conn, url, uid)?;
960 rows::forget_local(&db.conn, uid)?;
961 sent.insert(uid.clone());
962 out.sent += 1;
963 }
964 if cfg
968 .dsp
969 .removed
970 .iter()
971 .any(|u| sent.contains(u) || !(synced.contains_key(u) || locals.contains_key(u)))
972 {
973 Config::persist(|c| {
974 c.dsp
975 .removed
976 .retain(|u| !sent.contains(u) && (synced.contains_key(u) || locals.contains_key(u)))
977 })?;
978 }
979 let cfg = Config::cached();
983 let kept: HashSet<&str> = cfg
984 .dsp
985 .profiles
986 .iter()
987 .filter(|p| {
988 crate::audio::dsp::profiles::scope(p, &cfg.dsp.profiles) == DspScope::Everywhere
989 })
990 .filter_map(|p| p.uid.as_deref())
991 .collect();
992 for uid in edits
993 .keys()
994 .filter(|u| !kept.contains(u.as_str()) && !synced.contains_key(*u))
995 {
996 rows::forget_local(&db.conn, uid)?;
997 }
998 let mut kept_later = restore;
999 let mut uids: Vec<&String> = locals.keys().collect();
1000 uids.sort();
1001 for uid in uids {
1002 let local = &locals[uid];
1003 if synced.get(uid).is_some_and(|(_, h)| *h == local.hash) {
1004 continue;
1005 }
1006 let edited_at = match synced.get(uid) {
1009 Some(_) => edits.get(uid).map_or_else(now_ms, |e| e.edited_at),
1010 None => now_ms(),
1011 };
1012 let saved = match remote.save(uid, edited_at, &local.doc.json()) {
1013 Ok(saved) => saved,
1014 Err(SubsonicError::Api { message, .. }) => {
1015 rows::set_refused(&db.conn, uid, Some(&message))?;
1016 continue;
1017 }
1018 Err(e) => return Err(e.into()),
1019 };
1020 if !saved.stored {
1021 kept_later = true;
1022 continue;
1023 }
1024 for m in &saved.missing {
1025 let Some(path) = local.paths.get(&m.sha256) else {
1026 continue;
1027 };
1028 let bytes = std::fs::read(path)?;
1029 remote.upload(&m.sha256, bytes)?;
1030 }
1031 rows::set_synced(&db.conn, url, uid, saved.rev, &local.hash)?;
1032 rows::set_refused(&db.conn, uid, None)?;
1033 out.sent += 1;
1034 }
1035 let cfg = Config::cached();
1036 for output in cfg
1037 .dsp
1038 .autoeq_dismissed
1039 .iter()
1040 .filter(|d| !dismissed.contains(*d))
1041 {
1042 remote.dismiss(output)?;
1043 }
1044 Ok(kept_later)
1045}
1046
1047impl From<std::io::Error> for Failed {
1048 fn from(e: std::io::Error) -> Self {
1049 Self::Remote(SubsonicError::Io(e))
1050 }
1051}
1052
1053type Renamed = Vec<(Option<String>, String, String)>;
1059
1060fn adopt(
1061 uid: &str,
1062 doc: &SyncDoc,
1063 fetched: Vec<(String, Vec<u8>)>,
1064 reused: &HashMap<String, PathBuf>,
1065 synced: &HashMap<String, (i64, String)>,
1066) -> Result<Renamed, Failed> {
1067 use crate::audio::dsp::profiles;
1068 let cfg = Config::cached();
1069 let name = doc.profile.name.clone();
1070 let current = cfg
1071 .dsp
1072 .profiles
1073 .iter()
1074 .find(|p| p.uid.as_deref() == Some(uid));
1075 let mut name = name;
1079 let mut renamed = Vec::new();
1080 let folder = profiles::dir(&name);
1081 if let Some(holder) = cfg
1082 .dsp
1083 .profiles
1084 .iter()
1085 .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1086 && holder.uid.as_ref().is_some_and(|u| synced.contains_key(u))
1087 {
1088 let free = free_name(&cfg.dsp.profiles, &holder.name);
1092 profiles::rename(&holder.name, &free).map_err(io_failed)?;
1093 } else if let Some(holder) = cfg
1094 .dsp
1095 .profiles
1096 .iter()
1097 .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1098 {
1099 let here = this_device();
1103 let mine = by_device(&cfg.dsp.profiles, &holder.name, &here);
1104 profiles::rename(&holder.name, &mine).map_err(io_failed)?;
1105 renamed.push((holder.uid.clone(), holder.name.clone(), mine));
1106 let theirs = match doc.profile.origin.as_deref() {
1107 Some(origin) if origin != here => format!("{name} ({origin})"),
1108 _ => free_name(&Config::cached().dsp.profiles, &name),
1109 };
1110 let theirs = if Config::cached()
1111 .dsp
1112 .profiles
1113 .iter()
1114 .any(|p| p.name == theirs)
1115 {
1116 free_name(&Config::cached().dsp.profiles, &theirs)
1117 } else {
1118 theirs
1119 };
1120 renamed.push((Some(uid.to_owned()), name.clone(), theirs.clone()));
1121 name = theirs;
1122 }
1123 if let Some(current) = current
1124 && current.name != name
1125 {
1126 profiles::rename(¤t.name, &name).map_err(io_failed)?;
1127 }
1128
1129 let dir = profiles::dir(&name);
1131 let staged = dir.with_extension("incoming");
1132 let _ = std::fs::remove_dir_all(&staged);
1133 std::fs::create_dir_all(&staged)?;
1134 let fetched: HashMap<String, Vec<u8>> = fetched.into_iter().collect();
1135 for f in &doc.files {
1136 let target = staged.join(&f.name);
1137 match fetched.get(&f.sha256) {
1138 Some(bytes) => std::fs::write(&target, bytes)?,
1139 None => {
1140 let from = reused
1141 .get(&f.sha256)
1142 .ok_or_else(|| io_failed(format!("{} has not arrived", f.name)))?;
1143 std::fs::copy(from, &target)?;
1144 }
1145 }
1146 }
1147 let _ = std::fs::remove_dir_all(&dir);
1148 if doc.files.is_empty() {
1149 let _ = std::fs::remove_dir_all(&staged);
1150 } else {
1151 std::fs::rename(&staged, &dir)?;
1152 }
1153
1154 let relative = Path::new("dsp").join(dir.file_name().expect("a profile's folder has a name"));
1155 Config::persist(|c| {
1156 let mut incoming = doc.profile.clone();
1157 incoming.name = name.clone();
1158 incoming.impulses = incoming.impulses.iter().map(|f| relative.join(f)).collect();
1159 incoming.uid = Some(uid.to_owned());
1160 incoming.scope = Some(DspScope::Everywhere);
1161 match c
1162 .dsp
1163 .profiles
1164 .iter_mut()
1165 .find(|p| p.uid.as_deref() == Some(uid))
1166 {
1167 Some(p) => {
1168 incoming.devices = std::mem::take(&mut p.devices);
1169 *p = incoming;
1170 }
1171 None => {
1172 c.dsp.profiles.push(incoming);
1173 c.dsp.removed.retain(|u| u != uid);
1176 }
1177 }
1178 })?;
1179 Ok(renamed)
1180}
1181
1182fn by_device(profiles: &[DspProfile], name: &str, device: &str) -> String {
1185 let wanted = format!("{name} ({device})");
1186 if profiles.iter().any(|p| p.name == wanted) {
1187 free_name(profiles, &wanted)
1188 } else {
1189 wanted
1190 }
1191}
1192
1193fn drop_profile(uid: &str) -> Result<(), Failed> {
1196 let cfg = Config::cached();
1197 let Some(profile) = cfg
1198 .dsp
1199 .profiles
1200 .iter()
1201 .find(|p| p.uid.as_deref() == Some(uid))
1202 else {
1203 return Ok(());
1204 };
1205 if crate::audio::dsp::profiles::scope(profile, &cfg.dsp.profiles) == DspScope::Device {
1206 return Ok(());
1208 }
1209 let dir = crate::audio::dsp::profiles::dir(&profile.name);
1210 let shared =
1211 cfg.dsp.profiles.iter().any(|p| {
1212 p.uid.as_deref() != Some(uid) && crate::audio::dsp::profiles::dir(&p.name) == dir
1213 });
1214 Config::persist(|c| c.dsp.profiles.retain(|p| p.uid.as_deref() != Some(uid)))?;
1215 if !shared {
1216 let _ = std::fs::remove_dir_all(dir);
1217 }
1218 Ok(())
1219}
1220
1221fn free_name(profiles: &[DspProfile], name: &str) -> String {
1223 use crate::audio::dsp::profiles::dir;
1224 (2..)
1225 .map(|n| format!("{name} {n}"))
1226 .find(|candidate| {
1227 profiles
1228 .iter()
1229 .all(|p| &p.name != candidate && dir(&p.name) != dir(candidate))
1230 })
1231 .expect("some number is free")
1232}
1233
1234fn io_failed(e: impl ToString) -> Failed {
1235 Failed::Remote(SubsonicError::Io(std::io::Error::other(e.to_string())))
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use std::cell::RefCell;
1241 use std::path::Path;
1242
1243 use super::*;
1244 use crate::config::{DspFilter, DspTarget, EqFilter, EqFilterKind};
1245 use crate::remote::client::{KoanDspDismissed, KoanDspMissing, KoanDspProfile};
1246
1247 struct Server {
1249 conn: rusqlite::Connection,
1250 uploads: RefCell<usize>,
1251 }
1252
1253 const USER: i64 = 1;
1254
1255 impl Server {
1256 fn new() -> Self {
1257 let conn = rusqlite::Connection::open_in_memory().unwrap();
1258 crate::db::schema::create_tables(&conn).unwrap();
1259 conn.execute(
1260 "INSERT INTO users (id, username, password_hash, role) VALUES (1, 'mate', 'x', 'user')",
1261 [],
1262 )
1263 .unwrap();
1264 Self {
1265 conn,
1266 uploads: RefCell::new(0),
1267 }
1268 }
1269
1270 fn named(&self) -> HashMap<String, u64> {
1271 rows::live_docs(&self.conn, USER)
1272 .unwrap()
1273 .into_iter()
1274 .flat_map(|(_, json)| SyncDoc::parse(&json).unwrap().files)
1275 .map(|f| (f.sha256, f.size))
1276 .collect()
1277 }
1278 }
1279
1280 fn api(message: &str) -> SubsonicError {
1281 SubsonicError::Api {
1282 code: 0,
1283 message: message.into(),
1284 }
1285 }
1286
1287 impl Remote for Server {
1288 fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
1289 let (rows, cursor) = rows::changes(&self.conn, USER, since).unwrap();
1290 Ok(KoanDspProfiles {
1291 cursor,
1292 profile: rows
1293 .into_iter()
1294 .map(|r| KoanDspProfile {
1295 uid: r.uid,
1296 rev: r.rev,
1297 edited_at: r.edited_at,
1298 doc: r.doc,
1299 })
1300 .collect(),
1301 dismissed: rows::dismissed(&self.conn, USER)
1302 .unwrap()
1303 .into_iter()
1304 .map(|output| KoanDspDismissed { output })
1305 .collect(),
1306 })
1307 }
1308 fn save(
1309 &self,
1310 uid: &str,
1311 edited_at: i64,
1312 doc: &str,
1313 ) -> Result<KoanDspSaved, SubsonicError> {
1314 let parsed = SyncDoc::parse(doc).map_err(|e| api(&e))?;
1315 let saved = rows::save(&self.conn, USER, uid, edited_at, Some(doc)).unwrap();
1316 let held = rows::files(&self.conn, USER).unwrap();
1317 Ok(KoanDspSaved {
1318 rev: saved.rev,
1319 stored: saved.stored,
1320 missing: parsed
1321 .files
1322 .iter()
1323 .filter(|f| saved.stored && !held.contains_key(&f.sha256))
1324 .map(|f| KoanDspMissing {
1325 sha256: f.sha256.clone(),
1326 })
1327 .collect(),
1328 })
1329 }
1330 fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError> {
1331 let saved = rows::save(&self.conn, USER, uid, edited_at, None).unwrap();
1332 Ok(KoanDspSaved {
1333 rev: saved.rev,
1334 stored: saved.stored,
1335 missing: vec![],
1336 })
1337 }
1338 fn dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1339 rows::dismiss(&self.conn, USER, output).unwrap();
1340 Ok(())
1341 }
1342 fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1343 rows::file(&self.conn, USER, sha256)
1344 .unwrap()
1345 .ok_or_else(|| api("File not found"))
1346 }
1347 fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1348 assert_eq!(sha256_hex(&data), sha256);
1349 assert_eq!(self.named().get(sha256), Some(&(data.len() as u64)));
1350 rows::store_file(&self.conn, USER, sha256, &data).unwrap();
1351 *self.uploads.borrow_mut() += 1;
1352 Ok(())
1353 }
1354 }
1355
1356 struct Device {
1358 dir: tempfile::TempDir,
1359 db: Database,
1360 name: &'static str,
1361 }
1362
1363 impl Device {
1364 fn new() -> Self {
1365 Self::named("Device")
1366 }
1367
1368 fn named(name: &'static str) -> Self {
1369 let conn = rusqlite::Connection::open_in_memory().unwrap();
1370 crate::db::schema::create_tables(&conn).unwrap();
1371 Self {
1372 dir: tempfile::tempdir().unwrap(),
1373 db: Database { conn },
1374 name,
1375 }
1376 }
1377
1378 fn on(&self) -> &Self {
1380 config::set_config_dir(self.dir.path());
1381 set_device_name(Some(self.name.to_owned()));
1382 self
1383 }
1384
1385 fn sync(&self, server: &Server) -> DspSync {
1386 self.on();
1387 std::thread::sleep(std::time::Duration::from_millis(3));
1389 run(&self.db, server, "https://music.example")
1390 }
1391
1392 fn profiles(&self) -> Vec<DspProfile> {
1393 self.on();
1394 Config::cached().dsp.profiles.clone()
1395 }
1396
1397 fn profile(&self, name: &str) -> Option<DspProfile> {
1398 self.profiles().into_iter().find(|p| p.name == name)
1399 }
1400 }
1401
1402 fn band(gain_db: f64) -> DspFilter {
1403 DspFilter::Band(EqFilter {
1404 kind: EqFilterKind::Peaking,
1405 freq: 1000.0,
1406 gain_db,
1407 q: 1.0,
1408 channels: vec![],
1409 })
1410 }
1411
1412 fn headphone() -> DspProfile {
1413 DspProfile {
1414 name: "HD 650 (AutoEQ, oratory1990)".into(),
1415 devices: vec!["Topping E30".into()],
1416 filters: vec![band(3.0)],
1417 target: Some(DspTarget {
1418 made_for: "harman-over-ear-2018".into(),
1419 chosen: None,
1420 }),
1421 ..Default::default()
1422 }
1423 }
1424
1425 #[test]
1429 fn deferred_edits_wait_for_flush() {
1430 let _guard = lock();
1431 let here = Device::new();
1432 here.on();
1433 defer();
1434 changed();
1435 assert!(PENDING.load(Ordering::Relaxed));
1436 assert!(flush(&here.db).is_none());
1437 assert!(!PENDING.load(Ordering::Relaxed));
1438 assert!(flush(&here.db).is_none());
1439 DEFERRED.store(false, Ordering::Relaxed);
1440 }
1441
1442 fn lock() -> std::sync::MutexGuard<'static, ()> {
1443 crate::config::tests::PERSIST_LOCK
1444 .lock()
1445 .unwrap_or_else(|e| e.into_inner())
1446 }
1447
1448 #[test]
1452 fn profiles_follow_the_account() {
1453 let _guard = lock();
1454 let server = Server::new();
1455 let (a, b) = (Device::new(), Device::new());
1456
1457 a.on();
1458 Config::persist(|c| {
1459 c.dsp.profiles.push(headphone());
1460 c.dsp.profiles.push(DspProfile {
1461 name: "Desk speakers".into(),
1462 filters: vec![band(-2.0)],
1463 ..Default::default()
1464 });
1465 })
1466 .unwrap();
1467 assert_eq!(a.sync(&server).sent, 1);
1468 assert_eq!(
1469 a.profile("Desk speakers").unwrap().scope,
1470 Some(DspScope::Device),
1471 "kept here: it was here before syncing"
1472 );
1473
1474 b.sync(&server);
1475 let theirs = b.profile("HD 650 (AutoEQ, oratory1990)").unwrap();
1476 assert_eq!(theirs.filters, vec![band(3.0)]);
1477 assert!(
1478 theirs.devices.is_empty(),
1479 "which output uses it stays per device"
1480 );
1481 assert_eq!(
1482 theirs.uid,
1483 a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid
1484 );
1485 assert!(b.profile("Desk speakers").is_none());
1486
1487 a.on();
1490 Config::persist(|c| {
1491 let hd = c
1492 .dsp
1493 .profiles
1494 .iter_mut()
1495 .find(|p| p.name == "HD 650 (AutoEQ, oratory1990)")
1496 .unwrap();
1497 hd.filters[0] = band(5.0);
1498 })
1499 .unwrap();
1500 a.sync(&server);
1501 std::thread::sleep(std::time::Duration::from_millis(3));
1502 b.on();
1503 crate::audio::dsp::profiles::rename("HD 650 (AutoEQ, oratory1990)", "HD 650").unwrap();
1504 b.sync(&server);
1505 a.sync(&server);
1506 let a_now = a.profile("HD 650").expect("renamed on A too");
1507 assert_eq!(a_now.filters, vec![band(3.0)], "B's copy was edited later");
1508 assert_eq!(
1509 a_now.devices,
1510 vec!["Topping E30".to_string()],
1511 "A's output kept"
1512 );
1513 assert_eq!(b.profile("HD 650").unwrap().filters, vec![band(3.0)]);
1514
1515 b.on();
1517 crate::audio::dsp::profiles::delete("HD 650").unwrap();
1518 b.sync(&server);
1519 a.sync(&server);
1520 assert!(a.profile("HD 650").is_none());
1521 assert!(a.profile("Desk speakers").is_some());
1522 }
1523
1524 fn bass() -> DspProfile {
1527 DspProfile {
1528 name: "Bass".into(),
1529 filters: vec![band(4.0)],
1530 ..Default::default()
1531 }
1532 }
1533
1534 fn edit(name: &str, f: impl FnOnce(&mut DspProfile)) {
1535 Config::persist(|c| f(c.dsp.profiles.iter_mut().find(|p| p.name == name).unwrap()))
1536 .unwrap();
1537 }
1538
1539 fn removed() -> Vec<String> {
1540 Config::cached().dsp.removed.clone()
1541 }
1542
1543 fn shared(made: Vec<DspProfile>) -> (Server, Device, Device) {
1546 let server = Server::new();
1547 let (a, b) = (Device::new(), Device::new());
1548 a.on();
1549 Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
1550 a.sync(&server);
1551 a.on();
1552 Config::persist(|c| c.dsp.profiles.extend(made)).unwrap();
1553 a.sync(&server);
1554 b.sync(&server);
1555 (server, a, b)
1556 }
1557
1558 fn stays_shared(made: Vec<DspProfile>, name: &str, drift: impl FnOnce()) -> DspSync {
1562 let (server, a, b) = shared(made);
1563 assert!(b.profile(name).is_some(), "{name} reached B");
1564
1565 a.on();
1566 drift();
1567 let synced = a.sync(&server);
1568 b.sync(&server);
1569
1570 let uid = a.profile(name).unwrap().uid.unwrap();
1571 assert!(
1572 rows::live_docs(&server.conn, USER)
1573 .unwrap()
1574 .iter()
1575 .any(|(u, _)| *u == uid),
1576 "{name} was deleted from the server"
1577 );
1578 assert!(b.profile(name).is_some(), "{name} was deleted from B");
1579 assert_eq!(a.profile(name).unwrap().scope, Some(DspScope::Everywhere));
1580 synced
1581 }
1582
1583 #[test]
1584 fn a_shared_profile_set_for_a_built_in_output_stays_shared() {
1585 let _guard = lock();
1586 stays_shared(vec![bass()], "Bass", || {
1587 edit("Bass", |p| p.devices.push("Speaker".into()));
1588 });
1589 }
1590
1591 #[test]
1592 fn a_shared_profile_given_an_impulse_response_stays_shared() {
1593 let _guard = lock();
1594 stays_shared(vec![bass()], "Bass", || {
1595 let dir = crate::audio::dsp::profiles::dir("Bass");
1596 std::fs::create_dir_all(&dir).unwrap();
1597 std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1598 let rel = Path::new("dsp")
1599 .join(dir.file_name().unwrap())
1600 .join("48000.wav");
1601 edit("Bass", |p| p.impulses.push(rel));
1602 });
1603 }
1604
1605 fn local_layer() -> DspProfile {
1606 DspProfile {
1607 name: "Desk".into(),
1608 filters: vec![band(-2.0)],
1609 scope: Some(DspScope::Device),
1610 ..Default::default()
1611 }
1612 }
1613
1614 fn stack(group: bool) -> DspProfile {
1615 DspProfile {
1616 name: "Stack".into(),
1617 layers: vec![crate::config::DspLayer {
1618 profile: "Bass".into(),
1619 on: true,
1620 }],
1621 group,
1622 ..Default::default()
1623 }
1624 }
1625
1626 fn add_local_layer(on: bool) {
1627 Config::persist(|c| c.dsp.profiles.push(local_layer())).unwrap();
1628 edit("Stack", |s| {
1629 s.layers.push(crate::config::DspLayer {
1630 profile: "Desk".into(),
1631 on,
1632 })
1633 });
1634 }
1635
1636 #[test]
1637 fn a_shared_stack_given_a_layer_kept_here_stays_shared() {
1638 let _guard = lock();
1639 stays_shared(vec![bass(), stack(false)], "Stack", || {
1640 add_local_layer(true)
1641 });
1642 }
1643
1644 #[test]
1645 fn a_shared_group_given_a_member_kept_here_stays_shared() {
1646 let _guard = lock();
1647 stays_shared(vec![bass(), stack(true)], "Stack", || {
1648 add_local_layer(false)
1649 });
1650 }
1651
1652 #[test]
1656 fn a_synced_profile_that_drifted_before_this_build_stays_shared() {
1657 let _guard = lock();
1658 let synced = stays_shared(vec![bass()], "Bass", || {
1659 edit("Bass", |p| {
1660 p.scope = None;
1661 p.devices.push("Speaker".into());
1662 });
1663 });
1664 assert_eq!(synced.applied, 0, "kept as it was, not restored");
1665 }
1666
1667 #[test]
1671 fn a_profile_missing_without_a_deletion_is_restored_not_deleted() {
1672 let _guard = lock();
1673 let (server, a, b) = shared(vec![bass()]);
1674 a.on();
1675 Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1676 a.sync(&server);
1677 b.sync(&server);
1678
1679 assert!(b.profile("Bass").is_some(), "still on B");
1680 assert_eq!(
1681 a.profile("Bass").unwrap().filters,
1682 vec![band(4.0)],
1683 "back on A"
1684 );
1685 }
1686
1687 #[test]
1690 fn a_deletion_made_while_syncing_is_sent_by_the_next_sync() {
1691 let _guard = lock();
1692 let (server, a, b) = shared(vec![bass()]);
1693 a.on();
1694 let uid = a.profile("Bass").unwrap().uid.unwrap();
1695 Config::persist(|c| c.dsp.removed.push(uid.clone())).unwrap();
1697 a.sync(&server);
1698 a.on();
1699 assert_eq!(removed(), vec![uid.clone()], "not sent yet, still listed");
1700
1701 Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1702 a.sync(&server);
1703 b.sync(&server);
1704 assert!(b.profile("Bass").is_none(), "deleted on B");
1705 assert!(a.profile("Bass").is_none(), "not restored on A");
1706 a.on();
1707 assert!(removed().is_empty());
1708 }
1709
1710 #[test]
1714 fn a_profile_deleted_before_its_first_sync_stays_deleted() {
1715 let _guard = lock();
1716 let (server, a, b) = shared(vec![]);
1717 a.on();
1718 let uid = uuid::Uuid::now_v7().to_string();
1719 Config::persist(|c| {
1720 let mut new = bass();
1721 new.uid = Some(uid.clone());
1722 new.scope = Some(DspScope::Everywhere);
1723 c.dsp.profiles.push(new);
1724 c.dsp.removed.push(uid.clone());
1726 })
1727 .unwrap();
1728 a.sync(&server);
1729 a.on();
1730 assert_eq!(removed(), vec![uid.clone()], "kept while it is sent");
1731
1732 Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1733 a.sync(&server);
1734 b.sync(&server);
1735 assert!(a.profile("Bass").is_none(), "not restored on A");
1736 assert!(b.profile("Bass").is_none(), "never on B");
1737 a.on();
1738 assert!(removed().is_empty());
1739 }
1740
1741 #[test]
1744 fn deleting_a_profile_whose_edit_was_refused_deletes_it() {
1745 let _guard = lock();
1746 let (server, a, b) = shared(vec![bass()]);
1747 a.on();
1748 let dir = crate::audio::dsp::profiles::dir("Bass");
1749 std::fs::create_dir_all(&dir).unwrap();
1750 std::fs::File::create(dir.join("48000.wav"))
1751 .unwrap()
1752 .set_len(MAX_FILE + 1)
1753 .unwrap();
1754 a.sync(&server);
1755 a.on();
1756 assert!(refusal(&a.db, "Bass").is_some());
1757
1758 crate::audio::dsp::profiles::delete("Bass").unwrap();
1759 a.sync(&server);
1760 a.sync(&server);
1761 b.sync(&server);
1762 assert!(a.profile("Bass").is_none(), "not restored on A");
1763 assert!(b.profile("Bass").is_none(), "deleted on B");
1764 }
1765
1766 #[test]
1769 fn files_travel_with_their_profile() {
1770 let _guard = lock();
1771 let server = Server::new();
1772 let (a, b) = (Device::new(), Device::new());
1773 a.on();
1774 let dir = crate::audio::dsp::profiles::dir("Room");
1775 std::fs::create_dir_all(&dir).unwrap();
1776 std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1777 Config::persist(|c| {
1778 c.dsp.profiles.push(DspProfile {
1779 name: "Room".into(),
1780 impulses: vec![Path::new("dsp/room/48000.wav").into()],
1781 scope: Some(DspScope::Everywhere),
1782 ..Default::default()
1783 })
1784 })
1785 .unwrap();
1786 a.sync(&server);
1787 assert_eq!(*server.uploads.borrow(), 1);
1788 b.sync(&server);
1789 b.on();
1790 let room = b.profile("Room").unwrap();
1791 assert_eq!(room.impulses, vec![PathBuf::from("dsp/room/48000.wav")]);
1792 assert_eq!(
1793 std::fs::read(config::config_dir().join("dsp/room/48000.wav")).unwrap(),
1794 b"RIFF not really"
1795 );
1796
1797 a.on();
1799 crate::audio::dsp::profiles::set_scope("Room", DspScope::Device).unwrap();
1800 a.sync(&server);
1801 b.sync(&server);
1802 assert!(b.profile("Room").is_none());
1803 assert!(a.profile("Room").is_some());
1804 assert!(rows::local_edits(&a.db.conn).unwrap().is_empty());
1806 assert!(rows::local_edits(&b.db.conn).unwrap().is_empty());
1807 }
1808
1809 #[test]
1812 fn an_unsendable_profile_keeps_its_refusal() {
1813 let _guard = lock();
1814 let server = Server::new();
1815 let a = Device::new();
1816 a.on();
1817 let dir = crate::audio::dsp::profiles::dir("Huge");
1818 std::fs::create_dir_all(&dir).unwrap();
1819 std::fs::File::create(dir.join("48000.wav"))
1820 .unwrap()
1821 .set_len(MAX_FILE + 1)
1822 .unwrap();
1823 Config::persist(|c| {
1824 c.dsp.profiles.push(DspProfile {
1825 name: "Huge".into(),
1826 impulses: vec![Path::new("dsp/huge/48000.wav").into()],
1827 scope: Some(DspScope::Everywhere),
1828 ..Default::default()
1829 })
1830 })
1831 .unwrap();
1832 a.sync(&server);
1833 a.sync(&server);
1834 a.on();
1835 assert!(refusal(&a.db, "Huge").is_some());
1836 assert_eq!(rows::local_edits(&a.db.conn).unwrap().len(), 1);
1837 }
1838
1839 #[test]
1842 fn a_measured_correction_travels_whole() {
1843 use crate::audio::dsp::profiles;
1844 use crate::config::{DspEar, DspRole};
1845 let _guard = lock();
1846 let server = Server::new();
1847 let (a, b) = (Device::new(), Device::new());
1848 a.on();
1849 let mut text = String::from("frequency,raw\n");
1850 for hz in crate::audio::dsp::targets::grid() {
1851 text.push_str(&format!("{hz:.2},{:.1}\n", 90.0 + (hz / 1000.0).log2()));
1852 }
1853 profiles::save_measured("IEM", &text, DspEar::In, "diffuse-field-iso-11904-1").unwrap();
1854 let sent = a.profile("IEM").unwrap();
1855 let (doc, paths) = doc_of(&sent).unwrap();
1856 let back = SyncDoc::parse(&doc.json()).unwrap();
1857 assert_eq!(back.profile.fitted, sent.fitted);
1858 assert!(sent.fitted.is_some() && back.profile.filters == sent.filters);
1859 assert!(back.files.iter().any(|f| f.name == "measurement.csv"));
1860 assert!(paths.values().any(|p| p.ends_with("measurement.csv")));
1861
1862 a.sync(&server);
1863 b.sync(&server);
1864 b.on();
1865 let got = b.profile("IEM").unwrap();
1866 assert_eq!(got.fitted, sent.fitted);
1867 assert_eq!(profiles::role(&got), DspRole::Correction);
1868 a.on();
1869 let file = std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap();
1870 b.on();
1871 assert_eq!(
1872 std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap(),
1873 file
1874 );
1875
1876 a.on();
1878 profiles::set_role("IEM", DspRole::Baked).unwrap();
1879 a.sync(&server);
1880 b.sync(&server);
1881 assert_eq!(b.profile("IEM").unwrap().role, Some(DspRole::Baked));
1882 }
1883
1884 #[test]
1890 fn a_name_is_shared_once() {
1891 let _guard = lock();
1892 let server = Server::new();
1893 let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
1894 for (d, gain) in [(&a, 1.0), (&b, 2.0)] {
1895 d.on();
1896 let mut hp = headphone();
1897 hp.filters = vec![
1898 band(3.0),
1899 DspFilter::Band(EqFilter {
1900 kind: EqFilterKind::LowShelf,
1901 freq: 100.0,
1902 gain_db: 2.0 + if d.name == "iPhone" { 0.04 } else { 0.0 },
1903 q: 0.7,
1904 channels: vec![],
1905 }),
1906 ];
1907 if d.name == "iPhone" {
1908 hp.filters.reverse();
1909 }
1910 Config::persist(|c| {
1911 c.dsp.profiles.push(hp);
1912 c.dsp.profiles.push(DspProfile {
1913 name: "Lush".into(),
1914 filters: vec![band(gain)],
1915 scope: Some(DspScope::Everywhere),
1916 ..Default::default()
1917 });
1918 })
1919 .unwrap();
1920 }
1921 a.sync(&server);
1922 b.sync(&server);
1923 a.sync(&server);
1924 let names = |d: &Device| -> Vec<String> {
1925 let mut n: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
1926 n.sort();
1927 n
1928 };
1929 let all = vec![
1930 "HD 650 (AutoEQ, oratory1990)",
1931 "Lush (Mac Studio)",
1932 "Lush (iPhone)",
1933 ];
1934 assert_eq!(names(&a), all);
1935 assert_eq!(names(&b), all);
1936 assert_eq!(
1937 a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1938 b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1939 "reordered bands, 0.04 dB apart: the same profile"
1940 );
1941 assert_eq!(b.profile("Lush (iPhone)").unwrap().filters, vec![band(2.0)]);
1942 assert_eq!(
1943 a.profile("Lush (Mac Studio)").unwrap().filters,
1944 vec![band(1.0)]
1945 );
1946 b.on();
1947 let note = note(&b.db, "Lush (iPhone)").unwrap();
1948 assert!(note.starts_with("Renamed from “Lush”"), "{note}");
1949 }
1950
1951 #[test]
1954 fn a_profile_made_again_keeps_its_name() {
1955 use crate::audio::dsp::profiles;
1956 let _guard = lock();
1957 let server = Server::new();
1958 let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
1959 a.on();
1960 Config::persist(|c| {
1961 c.dsp.profiles.push(headphone());
1962 c.dsp.profiles.push(DspProfile {
1963 name: "Lush".into(),
1964 filters: vec![band(1.0)],
1965 scope: Some(DspScope::Everywhere),
1966 ..Default::default()
1967 });
1968 })
1969 .unwrap();
1970 a.sync(&server);
1971 b.sync(&server);
1972 a.on();
1973 profiles::delete("Lush").unwrap();
1974 profiles::delete("HD 650 (AutoEQ, oratory1990)").unwrap();
1975 let mut hp = headphone();
1976 hp.filters = vec![band(4.0)];
1977 Config::persist(|c| {
1978 c.dsp.profiles.push(hp);
1979 c.dsp.profiles.push(DspProfile {
1980 name: "Lush".into(),
1981 filters: vec![band(2.0)],
1982 scope: Some(DspScope::Everywhere),
1983 ..Default::default()
1984 });
1985 })
1986 .unwrap();
1987 a.sync(&server);
1988 b.sync(&server);
1989 a.sync(&server);
1990 for d in [&a, &b] {
1991 let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
1992 names.sort();
1993 assert_eq!(
1994 names,
1995 ["HD 650 (AutoEQ, oratory1990)", "Lush"],
1996 "{}",
1997 d.name
1998 );
1999 assert_eq!(d.profile("Lush").unwrap().filters, vec![band(2.0)]);
2000 assert_eq!(
2001 d.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2002 vec![band(4.0)]
2003 );
2004 d.on();
2005 assert_eq!(note(&d.db, "Lush"), None, "{}", d.name);
2006 }
2007 }
2008
2009 #[test]
2012 fn a_rename_chain_arrives_as_made() {
2013 use crate::audio::dsp::profiles;
2014 let _guard = lock();
2015 let server = Server::new();
2016 let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
2017 a.on();
2018 Config::persist(|c| {
2019 for (name, gain) in [("A", 1.0), ("B", 2.0)] {
2020 c.dsp.profiles.push(DspProfile {
2021 name: name.into(),
2022 filters: vec![band(gain)],
2023 scope: Some(DspScope::Everywhere),
2024 ..Default::default()
2025 });
2026 }
2027 })
2028 .unwrap();
2029 a.sync(&server);
2030 b.sync(&server);
2031 a.on();
2032 profiles::rename("B", "C").unwrap();
2033 profiles::rename("A", "B").unwrap();
2034 a.sync(&server);
2035 b.sync(&server);
2036 a.sync(&server);
2037 for d in [&a, &b] {
2038 let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
2039 names.sort();
2040 assert_eq!(names, ["B", "C"], "{}", d.name);
2041 assert_eq!(d.profile("B").unwrap().filters, vec![band(1.0)]);
2042 assert_eq!(d.profile("C").unwrap().filters, vec![band(2.0)]);
2043 }
2044 }
2045
2046 fn doc(filters: Vec<DspFilter>, files: Vec<SyncFile>) -> SyncDoc {
2047 SyncDoc {
2048 profile: DspProfile {
2049 name: "X".into(),
2050 filters,
2051 ..Default::default()
2052 },
2053 files,
2054 unknown: Default::default(),
2055 }
2056 }
2057
2058 #[test]
2061 fn a_newer_field_is_passed_on() {
2062 let json = r#"{"profile":{"name":"Warm","filters":[],"impulses":[],"devices":[],"source":[],"layers":[],"from_the_future":{"a":1}},"files":[]}"#;
2063 let doc = SyncDoc::parse(json).unwrap();
2064 assert_eq!(doc.profile.name, "Warm");
2065 assert_eq!(
2066 doc.unknown.get("from_the_future"),
2067 Some(&serde_json::json!({"a": 1}))
2068 );
2069 let again = SyncDoc::parse(&doc.json()).unwrap();
2070 assert_eq!(again, doc);
2071 assert!(doc.json().contains("from_the_future"));
2072 }
2073
2074 #[test]
2077 fn a_newer_field_is_not_an_edit() {
2078 let _guard = lock();
2079 let server = Server::new();
2080 let (a, b) = (Device::new(), Device::new());
2081 a.on();
2082 Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
2083 a.sync(&server);
2084 let (uid, json) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2085 let mut newer: serde_json::Value = serde_json::from_str(&json).unwrap();
2086 newer["profile"]["from_the_future"] = serde_json::json!({"a": 1});
2087 server.save(&uid, now_ms() + 1, &newer.to_string()).unwrap();
2088 for device in [&a, &b, &a, &b] {
2089 let synced = device.sync(&server);
2090 assert_eq!(synced.sent, 0, "{synced:?}");
2091 }
2092 let (_, kept) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2093 assert!(kept.contains("from_the_future"), "{kept}");
2094 assert_eq!(
2095 b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2096 vec![band(3.0)]
2097 );
2098 }
2099
2100 #[test]
2102 fn profiles_that_would_sound_the_same_are_the_same() {
2103 let none = |_: &str| None;
2104 let peak = |hz: f64, db: f64| {
2105 DspFilter::Band(EqFilter {
2106 kind: EqFilterKind::Peaking,
2107 freq: hz,
2108 gain_db: db,
2109 q: 1.0,
2110 channels: vec![],
2111 })
2112 };
2113 let a = doc(vec![peak(100.0, 3.0), peak(1000.0, -2.0)], vec![]);
2114 let reordered = doc(vec![peak(1000.0, -2.0), peak(100.0, 3.0)], vec![]);
2115 assert!(sounds_same(&a, &reordered, &none), "bands commute");
2116 let close = doc(vec![peak(100.0, 3.04), peak(1000.0, -2.0)], vec![]);
2117 assert!(sounds_same(&a, &close, &none), "0.04 dB is nothing heard");
2118 let extra = doc(
2119 vec![peak(100.0, 3.0), peak(1000.0, -2.0), peak(5000.0, 1.0)],
2120 vec![],
2121 );
2122 assert!(!sounds_same(&a, &extra, &none), "one band more");
2123 let measured = |target: &str| {
2124 let mut d = a.clone();
2125 d.profile.measurement = Some(crate::config::DspMeasurement {
2126 ear: crate::config::DspEar::In,
2127 target: target.into(),
2128 });
2129 d
2130 };
2131 assert!(
2132 !sounds_same(
2133 &measured("harman-in-ear-2019"),
2134 &measured("diffuse-field-iso-11904-1"),
2135 &none
2136 ),
2137 "one measurement corrected to two targets"
2138 );
2139 let tuned = |target: &str| {
2140 let mut d = a.clone();
2141 d.profile.tuned_for = Some(target.into());
2142 d
2143 };
2144 assert!(
2145 !sounds_same(
2146 &tuned("harman-over-ear-2018"),
2147 &tuned("diffuse-field-gras-kemar"),
2148 &none
2149 ),
2150 "one tuning made against two targets"
2151 );
2152 let mut grouped = a.clone();
2153 grouped.profile.group = true;
2154 assert!(
2155 !sounds_same(&a, &grouped, &none),
2156 "one at a time is not together"
2157 );
2158 let mut renamed = a.clone();
2159 renamed.profile.name = "Y".into();
2160 renamed.profile.origin = Some("iPhone".into());
2161 assert!(sounds_same(&a, &renamed, &none), "names and origins aside");
2162
2163 let delay = DspFilter::Delay(crate::config::Delay {
2165 ms: 1.0,
2166 ..Default::default()
2167 });
2168 let before = doc(
2169 vec![peak(100.0, 3.0), delay.clone(), peak(1000.0, -2.0)],
2170 vec![],
2171 );
2172 let after = doc(vec![peak(1000.0, -2.0), delay, peak(100.0, 3.0)], vec![]);
2173 assert!(!sounds_same(&before, &after, &none));
2174
2175 let ir = |sha: char| {
2177 let mut d = doc(
2178 vec![],
2179 vec![SyncFile {
2180 name: "48000.wav".into(),
2181 sha256: sha.to_string().repeat(64),
2182 size: 10,
2183 }],
2184 );
2185 d.profile.impulses = vec!["48000.wav".into()];
2186 d
2187 };
2188 assert!(sounds_same(&ir('a'), &ir('a'), &none));
2189 assert!(
2190 !sounds_same(&ir('a'), &ir('b'), &none),
2191 "a different response"
2192 );
2193 }
2194
2195 #[test]
2196 fn a_doc_cannot_name_a_file_outside_its_folder() {
2197 let doc = |name: &str| SyncDoc {
2198 profile: DspProfile {
2199 name: "Room".into(),
2200 ..Default::default()
2201 },
2202 files: vec![SyncFile {
2203 name: name.into(),
2204 sha256: "a".repeat(64),
2205 size: 1,
2206 }],
2207 unknown: Default::default(),
2208 };
2209 for bad in ["../config.toml", ".hidden", "a/b", ""] {
2210 assert!(SyncDoc::parse(&doc(bad).json()).is_err(), "{bad:?}");
2211 }
2212 let mut huge = doc("x.wav");
2213 huge.files[0].size = MAX_FILE + 1;
2214 assert!(SyncDoc::parse(&huge.json()).is_err());
2215 assert!(SyncDoc::parse(&doc("x.wav").json()).is_ok());
2216 }
2217
2218 #[test]
2221 fn a_profile_out_of_bounds_arrives_within_them() {
2222 let _guard = lock();
2223 let server = Server::new();
2224 let b = Device::new();
2225 let hostile = r#"{"profile":{"name":"Loud","devices":["Built-in Output"],
2226 "filters":[{"type":"delay","ms":1e12},{"type":"peaking","freq":1000.0,"gain_db":200.0,"q":1.0}],
2227 "impulses":[]},"files":[]}"#;
2228 rows::save(
2229 &server.conn,
2230 USER,
2231 "0199b5a2-6c1e-7cc3-9d2a-3f5b1e0c4d21",
2232 1,
2233 Some(hostile),
2234 )
2235 .unwrap();
2236 b.sync(&server);
2237 let loud = b.profile("Loud").expect("applied");
2238 assert!(loud.devices.is_empty(), "assigned to nothing here");
2239 assert_eq!(
2240 loud.filters[0],
2241 DspFilter::Delay(crate::config::Delay {
2242 ms: 2000.0,
2243 ..Default::default()
2244 })
2245 );
2246 b.on();
2247 let cfg = Config::cached();
2248 let setup = crate::audio::dsp::Setup::load(&loud, &cfg.dsp.profiles, &config::config_dir());
2249 assert!(setup.is_ok(), "it plays");
2250 }
2251
2252 #[test]
2256 fn a_folder_is_never_shared() {
2257 let _guard = lock();
2258 let server = Server::new();
2259 let (a, b) = (Device::named("Mac"), Device::named("iPhone"));
2260 b.on();
2261 let room = crate::audio::dsp::profiles::dir("Room");
2262 std::fs::create_dir_all(&room).unwrap();
2263 std::fs::write(room.join("48000.wav"), b"kept here").unwrap();
2264 Config::persist(|c| {
2265 c.dsp.profiles.push(DspProfile {
2266 name: "Room".into(),
2267 impulses: vec![Path::new("dsp/room/48000.wav").into()],
2268 scope: Some(DspScope::Device),
2269 ..Default::default()
2270 })
2271 })
2272 .unwrap();
2273 b.sync(&server);
2274 a.on();
2275 Config::persist(|c| {
2276 c.dsp.profiles.push(DspProfile {
2277 name: "ROOM".into(),
2278 filters: vec![band(1.0)],
2279 scope: Some(DspScope::Everywhere),
2280 ..Default::default()
2281 })
2282 })
2283 .unwrap();
2284 a.sync(&server);
2285 b.sync(&server);
2286 let renamed = b.profile("Room (iPhone)").expect("renamed, files and all");
2287 assert_eq!(renamed.scope, Some(DspScope::Device));
2288 b.on();
2289 assert_eq!(
2290 std::fs::read(config::config_dir().join(&renamed.impulses[0])).unwrap(),
2291 b"kept here"
2292 );
2293 assert!(b.profile("ROOM (Mac)").is_some());
2294
2295 Config::persist(|c| {
2297 for name in ["Desk", "desk!"] {
2298 c.dsp.profiles.push(DspProfile {
2299 name: name.into(),
2300 filters: vec![band(1.0)],
2301 scope: Some(DspScope::Everywhere),
2302 ..Default::default()
2303 });
2304 }
2305 })
2306 .unwrap();
2307 let cfg = Config::cached();
2308 let shared = cfg.dsp.profiles.iter().find(|p| p.name == "desk!").unwrap();
2309 assert!(doc_of(shared).unwrap_err().contains("shares its folder"));
2310 }
2311}